синхронизация с солана
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
# Universal Solana RPC configuration.
|
||||||
|
#
|
||||||
|
# Helius example:
|
||||||
|
# SOLANA_RPC_URL=https://devnet.helius-rpc.com/?api-key=YOUR_KEY
|
||||||
|
# SOLANA_WS_URL=wss://devnet.helius-rpc.com/?api-key=YOUR_KEY
|
||||||
|
#
|
||||||
|
# Standard/self-hosted Solana RPC example:
|
||||||
|
# SOLANA_RPC_URL=http://127.0.0.1:8899
|
||||||
|
# SOLANA_WS_URL=ws://127.0.0.1:8900
|
||||||
|
|
||||||
|
SOLANA_RPC_URL=
|
||||||
|
SOLANA_WS_URL=
|
||||||
|
|
||||||
|
SOLANA_PROGRAM_ID=put_your_program_id_here
|
||||||
|
SOLANA_COMMITMENT=confirmed
|
||||||
|
SQLITE_PATH=./data/solana.db
|
||||||
|
|
||||||
|
# Temporary backward-compatible Helius fallback.
|
||||||
|
# These are used only when SOLANA_RPC_URL / SOLANA_WS_URL are absent.
|
||||||
|
HELIUS_API_KEY=
|
||||||
|
SOLANA_NETWORK=devnet
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
target/
|
||||||
|
data/*.db
|
||||||
|
data/*.db-shm
|
||||||
|
data/*.db-wal
|
||||||
|
.env
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
Нужно также добавить в существующий AppConfig:
|
||||||
|
|
||||||
|
public String rpcUrl() {
|
||||||
|
String host = switch (network) {
|
||||||
|
case "devnet" -> "https://devnet.helius-rpc.com/";
|
||||||
|
case "mainnet" -> "https://mainnet.helius-rpc.com/";
|
||||||
|
default -> throw new IllegalStateException("Unsupported network: " + network);
|
||||||
|
};
|
||||||
|
return host + "?api-key=" + heliusApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
И в HeliusWebSocketClient:
|
||||||
|
|
||||||
|
1. Поле:
|
||||||
|
private final CompletableFuture<Void> subscribed = new CompletableFuture<>();
|
||||||
|
|
||||||
|
2. Метод:
|
||||||
|
public CompletableFuture<Void> subscribedFuture() {
|
||||||
|
return subscribed;
|
||||||
|
}
|
||||||
|
|
||||||
|
3. В обработчике успешной подписки после лога:
|
||||||
|
subscribed.complete(null);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Helius PDA Sync Test
|
||||||
|
|
||||||
|
Минимальное Java 21 приложение для проверки realtime-синхронизации:
|
||||||
|
|
||||||
|
`Solana Program -> Helius WebSocket -> programSubscribe -> SQLite`
|
||||||
|
|
||||||
|
## Что делает
|
||||||
|
|
||||||
|
- подключается к Helius WebSocket;
|
||||||
|
- подписывается через `programSubscribe` на изменения аккаунтов программы;
|
||||||
|
- сохраняет каждый полученный аккаунт в SQLite;
|
||||||
|
- обновляет запись только если новый `slot` не старее сохранённого;
|
||||||
|
- автоматически переподключается после обрыва соединения.
|
||||||
|
|
||||||
|
На этом этапе приложение хранит raw `base64` данные PDA и не декодирует Anchor/Borsh.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Java 21+
|
||||||
|
- Maven 3.9+
|
||||||
|
|
||||||
|
## Настройка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export HELIUS_API_KEY="your_key"
|
||||||
|
export SOLANA_PROGRAM_ID="your_program_id"
|
||||||
|
export SQLITE_PATH="./data/solana.db"
|
||||||
|
export SOLANA_COMMITMENT="confirmed"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn clean compile exec:java
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый лог:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Database initialized: ...
|
||||||
|
Connecting to Helius WebSocket...
|
||||||
|
WebSocket connected
|
||||||
|
Subscription request sent for program: ...
|
||||||
|
Subscribed successfully. Subscription id: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
После создания или изменения PDA:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Account update: ...
|
||||||
|
Saved account ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка SQLite
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 data/solana.db
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
.headers on
|
||||||
|
.mode column
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
address,
|
||||||
|
owner,
|
||||||
|
lamports,
|
||||||
|
slot,
|
||||||
|
length(data_base64) AS base64_length,
|
||||||
|
updated_at
|
||||||
|
FROM program_accounts
|
||||||
|
ORDER BY slot DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Первый тест
|
||||||
|
|
||||||
|
1. Запусти приложение.
|
||||||
|
2. Дождись `Subscribed successfully`.
|
||||||
|
3. Создай или измени PDA через контракт.
|
||||||
|
4. Убедись, что пришёл `programNotification`.
|
||||||
|
5. Проверь запись в SQLite.
|
||||||
|
|
||||||
|
Следующий шаг после проверки realtime: добавить `getProgramAccounts` на старте и декодирование конкретного PDA по IDL.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>helius-sync-test</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.release>17</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<okhttp.version>4.12.0</okhttp.version>
|
||||||
|
<jackson.version>2.17.2</jackson.version>
|
||||||
|
<sqlite.version>3.46.0.0</sqlite.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.squareup.okhttp3</groupId>
|
||||||
|
<artifactId>okhttp</artifactId>
|
||||||
|
<version>${okhttp.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
<version>${jackson.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xerial</groupId>
|
||||||
|
<artifactId>sqlite-jdbc</artifactId>
|
||||||
|
<version>${sqlite.version}</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.13.0</version>
|
||||||
|
<configuration>
|
||||||
|
<release>17</release>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
|
<version>3.4.1</version>
|
||||||
|
<configuration>
|
||||||
|
<mainClass>sync.Main</mainClass>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package sync;
|
||||||
|
|
||||||
|
import sync.config.AppConfig;
|
||||||
|
import sync.core.SyncCoordinator;
|
||||||
|
import sync.core.SyncMode;
|
||||||
|
import sync.source.ConnectionListener;
|
||||||
|
import sync.source.SolanaDataSource;
|
||||||
|
import sync.source.rpc.RpcSolanaDataSource;
|
||||||
|
import sync.storage.StorageRepository;
|
||||||
|
import sync.storage.sqlite.SQLiteStorageRepository;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
|
||||||
|
public final class Main {
|
||||||
|
|
||||||
|
private Main() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(
|
||||||
|
String[] args
|
||||||
|
) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
AppConfig config =
|
||||||
|
AppConfig
|
||||||
|
.fromEnvironment();
|
||||||
|
|
||||||
|
StorageRepository storage =
|
||||||
|
new SQLiteStorageRepository(
|
||||||
|
config.sqlitePath()
|
||||||
|
);
|
||||||
|
|
||||||
|
SolanaDataSource dataSource =
|
||||||
|
new RpcSolanaDataSource(
|
||||||
|
config.rpcUrl(),
|
||||||
|
config.websocketUrl(),
|
||||||
|
config.programId(),
|
||||||
|
config.commitment()
|
||||||
|
);
|
||||||
|
|
||||||
|
SyncCoordinator syncCoordinator =
|
||||||
|
new SyncCoordinator(
|
||||||
|
dataSource,
|
||||||
|
storage
|
||||||
|
);
|
||||||
|
|
||||||
|
Runtime
|
||||||
|
.getRuntime()
|
||||||
|
.addShutdownHook(
|
||||||
|
new Thread(
|
||||||
|
() -> {
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"Shutting down..."
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
dataSource.close();
|
||||||
|
|
||||||
|
storage.close();
|
||||||
|
|
||||||
|
} catch (Exception exception) {
|
||||||
|
|
||||||
|
System.err.println(
|
||||||
|
"Shutdown error: "
|
||||||
|
+ exception.getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
dataSource.startRealtime(
|
||||||
|
|
||||||
|
syncCoordinator
|
||||||
|
::handleRealtimeUpdate,
|
||||||
|
|
||||||
|
new ConnectionListener() {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConnected(
|
||||||
|
boolean firstConnection
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (firstConnection) {
|
||||||
|
|
||||||
|
CompletableFuture
|
||||||
|
.runAsync(
|
||||||
|
syncCoordinator
|
||||||
|
::start
|
||||||
|
);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
CompletableFuture
|
||||||
|
.runAsync(
|
||||||
|
() ->
|
||||||
|
syncCoordinator
|
||||||
|
.runMode(
|
||||||
|
SyncMode
|
||||||
|
.INCREMENTAL_RECOVERY
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisconnected(
|
||||||
|
Throwable cause
|
||||||
|
) {
|
||||||
|
|
||||||
|
System.err.println(
|
||||||
|
"Solana realtime disconnected: "
|
||||||
|
+ cause.getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
new CountDownLatch(
|
||||||
|
1
|
||||||
|
).await();
|
||||||
|
|
||||||
|
} catch (
|
||||||
|
InterruptedException exception
|
||||||
|
) {
|
||||||
|
|
||||||
|
Thread
|
||||||
|
.currentThread()
|
||||||
|
.interrupt();
|
||||||
|
|
||||||
|
} catch (Exception exception) {
|
||||||
|
|
||||||
|
System.err.println(
|
||||||
|
"Application startup failed:"
|
||||||
|
);
|
||||||
|
|
||||||
|
exception.printStackTrace();
|
||||||
|
|
||||||
|
System.exit(
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package sync.config;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
public record AppConfig(
|
||||||
|
String rpcUrl,
|
||||||
|
String websocketUrl,
|
||||||
|
String programId,
|
||||||
|
Path sqlitePath,
|
||||||
|
String commitment
|
||||||
|
) {
|
||||||
|
public static AppConfig fromEnvironment() {
|
||||||
|
String programId = requireEnv("SOLANA_PROGRAM_ID");
|
||||||
|
String sqlitePath = System.getenv().getOrDefault(
|
||||||
|
"SQLITE_PATH",
|
||||||
|
"./data/solana.db"
|
||||||
|
);
|
||||||
|
String commitment = System.getenv().getOrDefault(
|
||||||
|
"SOLANA_COMMITMENT",
|
||||||
|
"confirmed"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!commitment.equals("processed")
|
||||||
|
&& !commitment.equals("confirmed")
|
||||||
|
&& !commitment.equals("finalized")) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"SOLANA_COMMITMENT must be processed, confirmed or finalized"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String rpcUrl = optionalEnv("SOLANA_RPC_URL");
|
||||||
|
String websocketUrl = optionalEnv("SOLANA_WS_URL");
|
||||||
|
|
||||||
|
// Backward-compatible fallback for the current Helius-based .env.
|
||||||
|
if (rpcUrl == null || websocketUrl == null) {
|
||||||
|
String heliusApiKey = optionalEnv("HELIUS_API_KEY");
|
||||||
|
String network = System.getenv().getOrDefault(
|
||||||
|
"SOLANA_NETWORK",
|
||||||
|
"devnet"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (heliusApiKey == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Set SOLANA_RPC_URL and SOLANA_WS_URL, " +
|
||||||
|
"or provide HELIUS_API_KEY for the compatibility fallback"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!network.equals("devnet") && !network.equals("mainnet")) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"SOLANA_NETWORK must be devnet or mainnet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rpcUrl == null) {
|
||||||
|
rpcUrl = heliusRpcUrl(
|
||||||
|
network,
|
||||||
|
heliusApiKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (websocketUrl == null) {
|
||||||
|
websocketUrl = heliusWebSocketUrl(
|
||||||
|
network,
|
||||||
|
heliusApiKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AppConfig(
|
||||||
|
rpcUrl,
|
||||||
|
websocketUrl,
|
||||||
|
programId,
|
||||||
|
Path.of(sqlitePath),
|
||||||
|
commitment
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String heliusRpcUrl(
|
||||||
|
String network,
|
||||||
|
String apiKey
|
||||||
|
) {
|
||||||
|
return switch (network) {
|
||||||
|
case "devnet" ->
|
||||||
|
"https://devnet.helius-rpc.com/?api-key=" + apiKey;
|
||||||
|
case "mainnet" ->
|
||||||
|
"https://mainnet.helius-rpc.com/?api-key=" + apiKey;
|
||||||
|
default ->
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Unsupported network: " + network
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String heliusWebSocketUrl(
|
||||||
|
String network,
|
||||||
|
String apiKey
|
||||||
|
) {
|
||||||
|
return switch (network) {
|
||||||
|
case "devnet" ->
|
||||||
|
"wss://devnet.helius-rpc.com/?api-key=" + apiKey;
|
||||||
|
case "mainnet" ->
|
||||||
|
"wss://mainnet.helius-rpc.com/?api-key=" + apiKey;
|
||||||
|
default ->
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Unsupported network: " + network
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireEnv(
|
||||||
|
String name
|
||||||
|
) {
|
||||||
|
String value = optionalEnv(name);
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Missing required environment variable: " + name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String optionalEnv(
|
||||||
|
String name
|
||||||
|
) {
|
||||||
|
String value = System.getenv(name);
|
||||||
|
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package sync.core;
|
||||||
|
|
||||||
|
public record SyncCheckpoint(
|
||||||
|
SyncState state,
|
||||||
|
Long lastObservedSlot,
|
||||||
|
Long lastReconciledSlot,
|
||||||
|
Long lastFullSyncSlot
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package sync.core;
|
||||||
|
|
||||||
|
import sync.model.ProgramAccountUpdate;
|
||||||
|
import sync.model.RecoveryResult;
|
||||||
|
import sync.model.SnapshotResult;
|
||||||
|
import sync.source.SolanaDataSource;
|
||||||
|
import sync.storage.StorageRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
public final class SyncCoordinator {
|
||||||
|
|
||||||
|
private final SolanaDataSource
|
||||||
|
dataSource;
|
||||||
|
|
||||||
|
private final StorageRepository
|
||||||
|
storage;
|
||||||
|
|
||||||
|
private final AtomicBoolean modeRunning =
|
||||||
|
new AtomicBoolean(false);
|
||||||
|
|
||||||
|
public SyncCoordinator(
|
||||||
|
SolanaDataSource dataSource,
|
||||||
|
StorageRepository storage
|
||||||
|
) {
|
||||||
|
|
||||||
|
this.dataSource =
|
||||||
|
dataSource;
|
||||||
|
|
||||||
|
this.storage =
|
||||||
|
storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
boolean hasAccounts =
|
||||||
|
storage.hasAccounts();
|
||||||
|
|
||||||
|
SyncCheckpoint checkpoint =
|
||||||
|
storage
|
||||||
|
.getSyncCheckpoint();
|
||||||
|
|
||||||
|
if (!hasAccounts) {
|
||||||
|
|
||||||
|
runMode(
|
||||||
|
SyncMode.FULL_BOOTSTRAP
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
checkpoint
|
||||||
|
.lastReconciledSlot()
|
||||||
|
!= null
|
||||||
|
) {
|
||||||
|
|
||||||
|
runMode(
|
||||||
|
SyncMode.INCREMENTAL_RECOVERY
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runMode(
|
||||||
|
SyncMode.FULL_RECONCILIATION
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (Exception exception) {
|
||||||
|
|
||||||
|
markFailed();
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Sync coordinator failed",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void handleRealtimeUpdate(
|
||||||
|
ProgramAccountUpdate update
|
||||||
|
) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
storage.upsertAccount(
|
||||||
|
update
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Realtime advances only the observed boundary.
|
||||||
|
*
|
||||||
|
* A WebSocket event at slot 5000 does not prove
|
||||||
|
* that every relevant event before 5000 arrived.
|
||||||
|
*/
|
||||||
|
storage.updateLastObservedSlot(
|
||||||
|
update.slot()
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (Exception exception) {
|
||||||
|
|
||||||
|
markFailed();
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Realtime update failed",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void runMode(
|
||||||
|
SyncMode mode
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (!modeRunning.compareAndSet(
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
)) {
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"Sync mode skipped because another mode is running: "
|
||||||
|
+ mode
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
|
||||||
|
case FULL_BOOTSTRAP ->
|
||||||
|
fullBootstrap();
|
||||||
|
|
||||||
|
case REALTIME_SYNC ->
|
||||||
|
realtimeSync();
|
||||||
|
|
||||||
|
case INCREMENTAL_RECOVERY ->
|
||||||
|
incrementalRecovery();
|
||||||
|
|
||||||
|
case FULL_RECONCILIATION ->
|
||||||
|
fullReconciliation();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception exception) {
|
||||||
|
|
||||||
|
markFailed();
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Sync mode failed: "
|
||||||
|
+ mode,
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
|
||||||
|
modeRunning.set(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fullBootstrap()
|
||||||
|
throws Exception {
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"Sync mode: FULL_BOOTSTRAP"
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateSyncState(
|
||||||
|
SyncState.BOOTSTRAPPING
|
||||||
|
);
|
||||||
|
|
||||||
|
SnapshotResult snapshot =
|
||||||
|
dataSource
|
||||||
|
.loadFullSnapshot();
|
||||||
|
|
||||||
|
storage.upsertAccounts(
|
||||||
|
snapshot.accounts()
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateLastObservedSlot(
|
||||||
|
snapshot.snapshotSlot()
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateLastReconciledSlot(
|
||||||
|
snapshot.snapshotSlot()
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateLastFullSyncSlot(
|
||||||
|
snapshot.snapshotSlot()
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateSyncState(
|
||||||
|
SyncState.LIVE
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"FULL_BOOTSTRAP finished"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void realtimeSync()
|
||||||
|
throws Exception {
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"Sync mode: REALTIME_SYNC"
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateSyncState(
|
||||||
|
SyncState.LIVE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void incrementalRecovery()
|
||||||
|
throws Exception {
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"Sync mode: INCREMENTAL_RECOVERY"
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateSyncState(
|
||||||
|
SyncState.RECOVERING
|
||||||
|
);
|
||||||
|
|
||||||
|
SyncCheckpoint checkpoint =
|
||||||
|
storage
|
||||||
|
.getSyncCheckpoint();
|
||||||
|
|
||||||
|
Long lastReconciledSlot =
|
||||||
|
checkpoint
|
||||||
|
.lastReconciledSlot();
|
||||||
|
|
||||||
|
if (lastReconciledSlot == null) {
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"No reconciled checkpoint available. " +
|
||||||
|
"Falling back to full reconciliation."
|
||||||
|
);
|
||||||
|
|
||||||
|
fullReconciliation();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RecoveryResult recovery =
|
||||||
|
dataSource
|
||||||
|
.recoverFrom(
|
||||||
|
lastReconciledSlot
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!recovery.complete()) {
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"Incremental recovery incomplete. reason="
|
||||||
|
+ recovery.fallbackReason()
|
||||||
|
+ ". Falling back to full reconciliation."
|
||||||
|
);
|
||||||
|
|
||||||
|
fullReconciliation();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Apply current states of all program-owned accounts
|
||||||
|
* discovered during recovery.
|
||||||
|
*/
|
||||||
|
storage.upsertAccounts(
|
||||||
|
recovery.updates()
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* missingAddresses means only:
|
||||||
|
*
|
||||||
|
* "this touched address no longer exists on-chain".
|
||||||
|
*
|
||||||
|
* It may have been an unrelated temporary account.
|
||||||
|
*
|
||||||
|
* Only addresses already present in our local
|
||||||
|
* program_accounts projection are safe to delete.
|
||||||
|
*/
|
||||||
|
List<String> confirmedDeletedAddresses =
|
||||||
|
storage.findExistingAddresses(
|
||||||
|
recovery.missingAddresses()
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.deleteAccounts(
|
||||||
|
confirmedDeletedAddresses
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Only now can we advance the trusted continuous
|
||||||
|
* recovery boundary.
|
||||||
|
*/
|
||||||
|
storage.updateLastReconciledSlot(
|
||||||
|
recovery.toSlot()
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Do not lower lastObservedSlot.
|
||||||
|
*
|
||||||
|
* updateLastObservedSlot() uses MAX semantics,
|
||||||
|
* so realtime may already have moved it further.
|
||||||
|
*/
|
||||||
|
storage.updateLastObservedSlot(
|
||||||
|
recovery.toSlot()
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateSyncState(
|
||||||
|
SyncState.LIVE
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.printf(
|
||||||
|
"INCREMENTAL_RECOVERY finished. " +
|
||||||
|
"from=%d to=%d updates=%d missing=%d deleted=%d%n",
|
||||||
|
recovery.fromSlot(),
|
||||||
|
recovery.toSlot(),
|
||||||
|
recovery.updates().size(),
|
||||||
|
recovery.missingAddresses().size(),
|
||||||
|
confirmedDeletedAddresses.size()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fullReconciliation()
|
||||||
|
throws Exception {
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"Sync mode: FULL_RECONCILIATION"
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateSyncState(
|
||||||
|
SyncState.RECONCILING
|
||||||
|
);
|
||||||
|
|
||||||
|
SnapshotResult snapshot =
|
||||||
|
dataSource
|
||||||
|
.loadFullSnapshot();
|
||||||
|
|
||||||
|
storage.upsertAccounts(
|
||||||
|
snapshot.accounts()
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateLastObservedSlot(
|
||||||
|
snapshot.snapshotSlot()
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateLastReconciledSlot(
|
||||||
|
snapshot.snapshotSlot()
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateLastFullSyncSlot(
|
||||||
|
snapshot.snapshotSlot()
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateSyncState(
|
||||||
|
SyncState.LIVE
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"FULL_RECONCILIATION finished"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markFailed() {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
storage.updateSyncState(
|
||||||
|
SyncState.FAILED
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package sync.core;
|
||||||
|
|
||||||
|
public enum SyncMode {
|
||||||
|
FULL_BOOTSTRAP,
|
||||||
|
REALTIME_SYNC,
|
||||||
|
INCREMENTAL_RECOVERY,
|
||||||
|
FULL_RECONCILIATION
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package sync.core;
|
||||||
|
|
||||||
|
public enum SyncState {
|
||||||
|
EMPTY,
|
||||||
|
BOOTSTRAPPING,
|
||||||
|
LIVE,
|
||||||
|
RECOVERING,
|
||||||
|
RECONCILING,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package sync.model;
|
||||||
|
|
||||||
|
public record ProgramAccountUpdate(
|
||||||
|
String address,
|
||||||
|
String owner,
|
||||||
|
long lamports,
|
||||||
|
long slot,
|
||||||
|
String dataBase64,
|
||||||
|
boolean executable,
|
||||||
|
Long rentEpoch
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package sync.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record RecoveryResult(
|
||||||
|
boolean complete,
|
||||||
|
long fromSlot,
|
||||||
|
long toSlot,
|
||||||
|
List<ProgramAccountUpdate> updates,
|
||||||
|
List<String> missingAddresses,
|
||||||
|
String fallbackReason
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static RecoveryResult complete(
|
||||||
|
long fromSlot,
|
||||||
|
long toSlot,
|
||||||
|
List<ProgramAccountUpdate> updates,
|
||||||
|
List<String> missingAddresses
|
||||||
|
) {
|
||||||
|
return new RecoveryResult(
|
||||||
|
true,
|
||||||
|
fromSlot,
|
||||||
|
toSlot,
|
||||||
|
updates,
|
||||||
|
missingAddresses,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RecoveryResult incomplete(
|
||||||
|
long fromSlot,
|
||||||
|
long toSlot,
|
||||||
|
String fallbackReason
|
||||||
|
) {
|
||||||
|
return new RecoveryResult(
|
||||||
|
false,
|
||||||
|
fromSlot,
|
||||||
|
toSlot,
|
||||||
|
List.of(),
|
||||||
|
List.of(),
|
||||||
|
fallbackReason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package sync.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record SnapshotResult(
|
||||||
|
long snapshotSlot,
|
||||||
|
List<ProgramAccountUpdate> accounts
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package sync.source;
|
||||||
|
|
||||||
|
import sync.model.ProgramAccountUpdate;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface AccountUpdateListener {
|
||||||
|
|
||||||
|
void onAccountUpdate(
|
||||||
|
ProgramAccountUpdate update
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package sync.source;
|
||||||
|
|
||||||
|
public interface ConnectionListener {
|
||||||
|
|
||||||
|
void onConnected(
|
||||||
|
boolean firstConnection
|
||||||
|
);
|
||||||
|
|
||||||
|
void onDisconnected(
|
||||||
|
Throwable cause
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package sync.source;
|
||||||
|
|
||||||
|
import sync.model.RecoveryResult;
|
||||||
|
import sync.model.SnapshotResult;
|
||||||
|
|
||||||
|
public interface SolanaDataSource
|
||||||
|
extends AutoCloseable {
|
||||||
|
|
||||||
|
SnapshotResult loadFullSnapshot()
|
||||||
|
throws Exception;
|
||||||
|
|
||||||
|
void startRealtime(
|
||||||
|
AccountUpdateListener accountUpdateListener,
|
||||||
|
ConnectionListener connectionListener
|
||||||
|
) throws Exception;
|
||||||
|
|
||||||
|
void stopRealtime();
|
||||||
|
|
||||||
|
RecoveryResult recoverFrom(
|
||||||
|
long lastProcessedSlot
|
||||||
|
) throws Exception;
|
||||||
|
|
||||||
|
long getCurrentSlot()
|
||||||
|
throws Exception;
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package sync.source.rpc;
|
||||||
|
|
||||||
|
import sync.model.RecoveryResult;
|
||||||
|
import sync.model.SnapshotResult;
|
||||||
|
import sync.source.AccountUpdateListener;
|
||||||
|
import sync.source.ConnectionListener;
|
||||||
|
import sync.source.SolanaDataSource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public final class RpcSolanaDataSource
|
||||||
|
implements SolanaDataSource {
|
||||||
|
|
||||||
|
private final SolanaRpcClient
|
||||||
|
rpcClient;
|
||||||
|
|
||||||
|
private final SolanaWebSocketClient
|
||||||
|
webSocketClient;
|
||||||
|
|
||||||
|
public RpcSolanaDataSource(
|
||||||
|
String rpcUrl,
|
||||||
|
String websocketUrl,
|
||||||
|
String programId,
|
||||||
|
String commitment
|
||||||
|
) {
|
||||||
|
|
||||||
|
this.rpcClient =
|
||||||
|
new SolanaRpcClient(
|
||||||
|
rpcUrl,
|
||||||
|
programId,
|
||||||
|
commitment
|
||||||
|
);
|
||||||
|
|
||||||
|
this.webSocketClient =
|
||||||
|
new SolanaWebSocketClient(
|
||||||
|
websocketUrl,
|
||||||
|
programId,
|
||||||
|
commitment
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SnapshotResult loadFullSnapshot()
|
||||||
|
throws Exception {
|
||||||
|
|
||||||
|
return rpcClient
|
||||||
|
.loadFullSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startRealtime(
|
||||||
|
AccountUpdateListener accountUpdateListener,
|
||||||
|
ConnectionListener connectionListener
|
||||||
|
) {
|
||||||
|
|
||||||
|
webSocketClient.start(
|
||||||
|
accountUpdateListener,
|
||||||
|
connectionListener
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stopRealtime() {
|
||||||
|
|
||||||
|
webSocketClient.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RecoveryResult recoverFrom(
|
||||||
|
long lastReconciledSlot
|
||||||
|
) throws Exception {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fix the upper boundary at the beginning.
|
||||||
|
*
|
||||||
|
* Realtime is already active and will handle
|
||||||
|
* anything newer than targetSlot.
|
||||||
|
*/
|
||||||
|
long targetSlot =
|
||||||
|
rpcClient
|
||||||
|
.getCurrentSlot();
|
||||||
|
|
||||||
|
System.out.printf(
|
||||||
|
"Incremental recovery requested. fromSlot=%d targetSlot=%d%n",
|
||||||
|
lastReconciledSlot,
|
||||||
|
targetSlot
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetSlot
|
||||||
|
<= lastReconciledSlot) {
|
||||||
|
|
||||||
|
return RecoveryResult.complete(
|
||||||
|
lastReconciledSlot,
|
||||||
|
targetSlot,
|
||||||
|
List.of(),
|
||||||
|
List.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
long firstAvailableBlock =
|
||||||
|
rpcClient
|
||||||
|
.getFirstAvailableBlock();
|
||||||
|
|
||||||
|
if (firstAvailableBlock < 0) {
|
||||||
|
|
||||||
|
return RecoveryResult.incomplete(
|
||||||
|
lastReconciledSlot,
|
||||||
|
targetSlot,
|
||||||
|
"cannot_resolve_first_available_block"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The RPC node has already pruned the checkpoint
|
||||||
|
* range. Incremental recovery cannot be proven.
|
||||||
|
*/
|
||||||
|
if (lastReconciledSlot
|
||||||
|
< firstAvailableBlock) {
|
||||||
|
|
||||||
|
return RecoveryResult.incomplete(
|
||||||
|
lastReconciledSlot,
|
||||||
|
targetSlot,
|
||||||
|
"checkpoint_older_than_rpc_history"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
List<SolanaRpcClient.SignatureInfo>
|
||||||
|
signatures =
|
||||||
|
rpcClient
|
||||||
|
.getSignaturesAfterSlot(
|
||||||
|
lastReconciledSlot,
|
||||||
|
targetSlot
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.printf(
|
||||||
|
"Recovery transactions found: %d%n",
|
||||||
|
signatures.size()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signatures.isEmpty()) {
|
||||||
|
|
||||||
|
return RecoveryResult.complete(
|
||||||
|
lastReconciledSlot,
|
||||||
|
targetSlot,
|
||||||
|
List.of(),
|
||||||
|
List.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> touchedAddresses =
|
||||||
|
rpcClient
|
||||||
|
.getTouchedAddresses(
|
||||||
|
signatures
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.printf(
|
||||||
|
"Recovery unique touched addresses: %d%n",
|
||||||
|
touchedAddresses.size()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (touchedAddresses.isEmpty()) {
|
||||||
|
|
||||||
|
return RecoveryResult.complete(
|
||||||
|
lastReconciledSlot,
|
||||||
|
targetSlot,
|
||||||
|
List.of(),
|
||||||
|
List.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SolanaRpcClient.AccountBatchResult
|
||||||
|
accountBatchResult =
|
||||||
|
rpcClient
|
||||||
|
.getCurrentAccounts(
|
||||||
|
touchedAddresses,
|
||||||
|
targetSlot
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.printf(
|
||||||
|
"Recovery state resolved: updates=%d missing=%d%n",
|
||||||
|
accountBatchResult
|
||||||
|
.updates()
|
||||||
|
.size(),
|
||||||
|
accountBatchResult
|
||||||
|
.missingAddresses()
|
||||||
|
.size()
|
||||||
|
);
|
||||||
|
|
||||||
|
return RecoveryResult.complete(
|
||||||
|
lastReconciledSlot,
|
||||||
|
targetSlot,
|
||||||
|
accountBatchResult
|
||||||
|
.updates(),
|
||||||
|
accountBatchResult
|
||||||
|
.missingAddresses()
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (IOException exception) {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Missing transaction history, unsupported
|
||||||
|
* transaction format or unavailable RPC history
|
||||||
|
* means we cannot claim a complete recovery.
|
||||||
|
*
|
||||||
|
* Coordinator will fall back to a full snapshot.
|
||||||
|
*/
|
||||||
|
|
||||||
|
System.err.println(
|
||||||
|
"Incremental recovery RPC failure: "
|
||||||
|
+ exception.getMessage()
|
||||||
|
);
|
||||||
|
|
||||||
|
return RecoveryResult.incomplete(
|
||||||
|
lastReconciledSlot,
|
||||||
|
targetSlot,
|
||||||
|
"rpc_history_recovery_failed: "
|
||||||
|
+ exception.getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getCurrentSlot()
|
||||||
|
throws Exception {
|
||||||
|
|
||||||
|
return rpcClient
|
||||||
|
.getCurrentSlot();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
|
||||||
|
webSocketClient.close();
|
||||||
|
|
||||||
|
rpcClient.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
package sync.source.rpc;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import okhttp3.*;
|
||||||
|
import sync.model.ProgramAccountUpdate;
|
||||||
|
import sync.model.SnapshotResult;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
public final class SolanaRpcClient
|
||||||
|
implements AutoCloseable {
|
||||||
|
|
||||||
|
private static final MediaType JSON =
|
||||||
|
MediaType.get("application/json");
|
||||||
|
|
||||||
|
private static final int SIGNATURE_PAGE_SIZE =
|
||||||
|
1000;
|
||||||
|
|
||||||
|
private static final int ACCOUNT_BATCH_SIZE =
|
||||||
|
100;
|
||||||
|
|
||||||
|
private final String rpcUrl;
|
||||||
|
private final String programId;
|
||||||
|
private final String commitment;
|
||||||
|
|
||||||
|
private final ObjectMapper mapper =
|
||||||
|
new ObjectMapper();
|
||||||
|
|
||||||
|
private final OkHttpClient httpClient =
|
||||||
|
new OkHttpClient.Builder()
|
||||||
|
.callTimeout(Duration.ofMinutes(2))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
public SolanaRpcClient(
|
||||||
|
String rpcUrl,
|
||||||
|
String programId,
|
||||||
|
String commitment
|
||||||
|
) {
|
||||||
|
this.rpcUrl = rpcUrl;
|
||||||
|
this.programId = programId;
|
||||||
|
this.commitment = commitment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SnapshotResult loadFullSnapshot()
|
||||||
|
throws IOException {
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"Full snapshot loading started..."
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 100,
|
||||||
|
"method", "getProgramAccounts",
|
||||||
|
"params", List.of(
|
||||||
|
programId,
|
||||||
|
Map.of(
|
||||||
|
"encoding", "base64",
|
||||||
|
"commitment", commitment,
|
||||||
|
"withContext", true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
JsonNode result =
|
||||||
|
root.path("result");
|
||||||
|
|
||||||
|
long snapshotSlot =
|
||||||
|
result
|
||||||
|
.path("context")
|
||||||
|
.path("slot")
|
||||||
|
.asLong(-1);
|
||||||
|
|
||||||
|
if (snapshotSlot < 0) {
|
||||||
|
throw new IOException(
|
||||||
|
"Missing context.slot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode values =
|
||||||
|
result.path("value");
|
||||||
|
|
||||||
|
if (!values.isArray()) {
|
||||||
|
throw new IOException(
|
||||||
|
"Unexpected getProgramAccounts response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ProgramAccountUpdate> accounts =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
for (JsonNode item : values) {
|
||||||
|
|
||||||
|
JsonNode account =
|
||||||
|
item.path("account");
|
||||||
|
|
||||||
|
JsonNode data =
|
||||||
|
account.path("data");
|
||||||
|
|
||||||
|
if (!data.isArray()
|
||||||
|
|| data.isEmpty()) {
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts.add(
|
||||||
|
parseAccount(
|
||||||
|
item.path("pubkey")
|
||||||
|
.asText(),
|
||||||
|
account,
|
||||||
|
snapshotSlot
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.printf(
|
||||||
|
"Full snapshot loaded. accounts=%d snapshotSlot=%d%n",
|
||||||
|
accounts.size(),
|
||||||
|
snapshotSlot
|
||||||
|
);
|
||||||
|
|
||||||
|
return new SnapshotResult(
|
||||||
|
snapshotSlot,
|
||||||
|
accounts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getCurrentSlot()
|
||||||
|
throws IOException {
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 101,
|
||||||
|
"method", "getSlot",
|
||||||
|
"params", List.of(
|
||||||
|
Map.of(
|
||||||
|
"commitment",
|
||||||
|
commitment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
long slot =
|
||||||
|
root.path("result")
|
||||||
|
.asLong(-1);
|
||||||
|
|
||||||
|
if (slot < 0) {
|
||||||
|
throw new IOException(
|
||||||
|
"Invalid getSlot response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getFirstAvailableBlock()
|
||||||
|
throws IOException {
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 102,
|
||||||
|
"method", "getFirstAvailableBlock"
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
return root
|
||||||
|
.path("result")
|
||||||
|
.asLong(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SignatureInfo> getSignaturesAfterSlot(
|
||||||
|
long fromSlot,
|
||||||
|
long targetSlot
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
List<SignatureInfo> signatures =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
String before =
|
||||||
|
null;
|
||||||
|
|
||||||
|
boolean lowerBoundaryReached =
|
||||||
|
false;
|
||||||
|
|
||||||
|
while (!lowerBoundaryReached) {
|
||||||
|
|
||||||
|
Map<String, Object> options =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
|
||||||
|
options.put(
|
||||||
|
"limit",
|
||||||
|
SIGNATURE_PAGE_SIZE
|
||||||
|
);
|
||||||
|
|
||||||
|
options.put(
|
||||||
|
"commitment",
|
||||||
|
commitment
|
||||||
|
);
|
||||||
|
|
||||||
|
if (before != null) {
|
||||||
|
|
||||||
|
options.put(
|
||||||
|
"before",
|
||||||
|
before
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 103,
|
||||||
|
"method", "getSignaturesForAddress",
|
||||||
|
"params", List.of(
|
||||||
|
programId,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
JsonNode values =
|
||||||
|
root.path("result");
|
||||||
|
|
||||||
|
if (!values.isArray()) {
|
||||||
|
|
||||||
|
throw new IOException(
|
||||||
|
"Unexpected getSignaturesForAddress response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (JsonNode item : values) {
|
||||||
|
|
||||||
|
long slot =
|
||||||
|
item.path("slot")
|
||||||
|
.asLong(-1);
|
||||||
|
|
||||||
|
if (slot < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* We reached the already reconciled range.
|
||||||
|
*/
|
||||||
|
if (slot <= fromSlot) {
|
||||||
|
|
||||||
|
lowerBoundaryReached =
|
||||||
|
true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Realtime is already running.
|
||||||
|
*
|
||||||
|
* Recovery covers only:
|
||||||
|
*
|
||||||
|
* (fromSlot, targetSlot]
|
||||||
|
*/
|
||||||
|
if (slot > targetSlot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String signature =
|
||||||
|
item.path("signature")
|
||||||
|
.asText();
|
||||||
|
|
||||||
|
if (signature.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Failed transactions cannot have produced
|
||||||
|
* successful account state changes.
|
||||||
|
*/
|
||||||
|
JsonNode error =
|
||||||
|
item.get("err");
|
||||||
|
|
||||||
|
if (error != null
|
||||||
|
&& !error.isNull()) {
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
signatures.add(
|
||||||
|
new SignatureInfo(
|
||||||
|
signature,
|
||||||
|
slot
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerBoundaryReached) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode last =
|
||||||
|
values.get(
|
||||||
|
values.size() - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
before =
|
||||||
|
last.path("signature")
|
||||||
|
.asText();
|
||||||
|
|
||||||
|
if (before.isBlank()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.size()
|
||||||
|
< SIGNATURE_PAGE_SIZE) {
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return signatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> getTouchedAddresses(
|
||||||
|
List<SignatureInfo> signatures
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
Set<String> addresses =
|
||||||
|
new LinkedHashSet<>();
|
||||||
|
|
||||||
|
for (
|
||||||
|
SignatureInfo signatureInfo
|
||||||
|
: signatures
|
||||||
|
) {
|
||||||
|
|
||||||
|
JsonNode transaction =
|
||||||
|
getTransaction(
|
||||||
|
signatureInfo.signature()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (transaction == null
|
||||||
|
|| transaction.isNull()) {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If a transaction that belongs to the recovery
|
||||||
|
* range cannot be read, we cannot claim that
|
||||||
|
* recovery is complete.
|
||||||
|
*/
|
||||||
|
throw new IOException(
|
||||||
|
"Transaction unavailable during recovery: "
|
||||||
|
+ signatureInfo.signature()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode message =
|
||||||
|
transaction
|
||||||
|
.path("transaction")
|
||||||
|
.path("message");
|
||||||
|
|
||||||
|
JsonNode accountKeys =
|
||||||
|
message.path(
|
||||||
|
"accountKeys"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!accountKeys.isArray()) {
|
||||||
|
|
||||||
|
throw new IOException(
|
||||||
|
"Missing accountKeys for transaction: "
|
||||||
|
+ signatureInfo.signature()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (JsonNode keyNode
|
||||||
|
: accountKeys) {
|
||||||
|
|
||||||
|
String pubkey;
|
||||||
|
|
||||||
|
if (keyNode.isTextual()) {
|
||||||
|
|
||||||
|
pubkey =
|
||||||
|
keyNode.asText();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
pubkey =
|
||||||
|
keyNode
|
||||||
|
.path("pubkey")
|
||||||
|
.asText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pubkey.isBlank()) {
|
||||||
|
|
||||||
|
addresses.add(
|
||||||
|
pubkey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Do not treat the executable program account
|
||||||
|
* itself as a PDA candidate.
|
||||||
|
*/
|
||||||
|
addresses.remove(
|
||||||
|
programId
|
||||||
|
);
|
||||||
|
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AccountBatchResult getCurrentAccounts(
|
||||||
|
Collection<String> addresses,
|
||||||
|
long recoverySlot
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
List<ProgramAccountUpdate> updates =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
List<String> missingAddresses =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
List<String> addressList =
|
||||||
|
new ArrayList<>(
|
||||||
|
addresses
|
||||||
|
);
|
||||||
|
|
||||||
|
for (
|
||||||
|
int offset = 0;
|
||||||
|
offset < addressList.size();
|
||||||
|
offset += ACCOUNT_BATCH_SIZE
|
||||||
|
) {
|
||||||
|
|
||||||
|
int end =
|
||||||
|
Math.min(
|
||||||
|
offset
|
||||||
|
+ ACCOUNT_BATCH_SIZE,
|
||||||
|
addressList.size()
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> batch =
|
||||||
|
addressList.subList(
|
||||||
|
offset,
|
||||||
|
end
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 105,
|
||||||
|
"method", "getMultipleAccounts",
|
||||||
|
"params", List.of(
|
||||||
|
batch,
|
||||||
|
Map.of(
|
||||||
|
"encoding",
|
||||||
|
"base64",
|
||||||
|
"commitment",
|
||||||
|
commitment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
JsonNode values =
|
||||||
|
root
|
||||||
|
.path("result")
|
||||||
|
.path("value");
|
||||||
|
|
||||||
|
if (!values.isArray()) {
|
||||||
|
|
||||||
|
throw new IOException(
|
||||||
|
"Unexpected getMultipleAccounts response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.size()
|
||||||
|
!= batch.size()) {
|
||||||
|
|
||||||
|
throw new IOException(
|
||||||
|
"getMultipleAccounts returned unexpected account count"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
int i = 0;
|
||||||
|
i < batch.size();
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
|
||||||
|
String address =
|
||||||
|
batch.get(i);
|
||||||
|
|
||||||
|
JsonNode account =
|
||||||
|
values.get(i);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The source only reports that the account
|
||||||
|
* does not currently exist.
|
||||||
|
*
|
||||||
|
* It does NOT decide whether the local
|
||||||
|
* storage should delete anything.
|
||||||
|
*/
|
||||||
|
if (account == null
|
||||||
|
|| account.isNull()) {
|
||||||
|
|
||||||
|
missingAddresses.add(
|
||||||
|
address
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String owner =
|
||||||
|
account.path("owner")
|
||||||
|
.asText("");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Only accounts currently owned by our
|
||||||
|
* program belong in the projection.
|
||||||
|
*/
|
||||||
|
if (!programId.equals(owner)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode data =
|
||||||
|
account.path("data");
|
||||||
|
|
||||||
|
if (!data.isArray()
|
||||||
|
|| data.isEmpty()) {
|
||||||
|
|
||||||
|
throw new IOException(
|
||||||
|
"Unexpected account.data format for "
|
||||||
|
+ address
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.add(
|
||||||
|
parseAccount(
|
||||||
|
address,
|
||||||
|
account,
|
||||||
|
recoverySlot
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AccountBatchResult(
|
||||||
|
updates,
|
||||||
|
missingAddresses
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode getTransaction(
|
||||||
|
String signature
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 104,
|
||||||
|
"method", "getTransaction",
|
||||||
|
"params", List.of(
|
||||||
|
signature,
|
||||||
|
Map.of(
|
||||||
|
"encoding",
|
||||||
|
"jsonParsed",
|
||||||
|
"commitment",
|
||||||
|
commitment,
|
||||||
|
"maxSupportedTransactionVersion",
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
JsonNode result =
|
||||||
|
root.get("result");
|
||||||
|
|
||||||
|
if (result == null
|
||||||
|
|| result.isNull()) {
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProgramAccountUpdate parseAccount(
|
||||||
|
String address,
|
||||||
|
JsonNode account,
|
||||||
|
long slot
|
||||||
|
) {
|
||||||
|
|
||||||
|
JsonNode data =
|
||||||
|
account.path("data");
|
||||||
|
|
||||||
|
return new ProgramAccountUpdate(
|
||||||
|
address,
|
||||||
|
account.path("owner")
|
||||||
|
.asText(),
|
||||||
|
account.path("lamports")
|
||||||
|
.asLong(),
|
||||||
|
slot,
|
||||||
|
data.get(0)
|
||||||
|
.asText(),
|
||||||
|
account.path("executable")
|
||||||
|
.asBoolean(false),
|
||||||
|
account.hasNonNull(
|
||||||
|
"rentEpoch"
|
||||||
|
)
|
||||||
|
? account
|
||||||
|
.get("rentEpoch")
|
||||||
|
.asLong()
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode executeRpc(
|
||||||
|
Map<String, Object> payload
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
Request request =
|
||||||
|
new Request.Builder()
|
||||||
|
.url(rpcUrl)
|
||||||
|
.post(
|
||||||
|
RequestBody.create(
|
||||||
|
mapper.writeValueAsBytes(
|
||||||
|
payload
|
||||||
|
),
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (Response response =
|
||||||
|
httpClient
|
||||||
|
.newCall(request)
|
||||||
|
.execute()) {
|
||||||
|
|
||||||
|
if (!response.isSuccessful()) {
|
||||||
|
|
||||||
|
throw new IOException(
|
||||||
|
"Solana RPC HTTP error: "
|
||||||
|
+ response.code()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseBody body =
|
||||||
|
response.body();
|
||||||
|
|
||||||
|
if (body == null) {
|
||||||
|
|
||||||
|
throw new IOException(
|
||||||
|
"Solana RPC returned empty body"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
mapper.readTree(
|
||||||
|
body.string()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (root.has("error")) {
|
||||||
|
|
||||||
|
throw new IOException(
|
||||||
|
"Solana RPC error: "
|
||||||
|
+ root.get("error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SignatureInfo(
|
||||||
|
String signature,
|
||||||
|
long slot
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AccountBatchResult(
|
||||||
|
List<ProgramAccountUpdate> updates,
|
||||||
|
List<String> missingAddresses
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
|
||||||
|
httpClient
|
||||||
|
.dispatcher()
|
||||||
|
.executorService()
|
||||||
|
.shutdown();
|
||||||
|
|
||||||
|
httpClient
|
||||||
|
.connectionPool()
|
||||||
|
.evictAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
package sync.source.rpc;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import okhttp3.*;
|
||||||
|
import sync.model.ProgramAccountUpdate;
|
||||||
|
import sync.source.AccountUpdateListener;
|
||||||
|
import sync.source.ConnectionListener;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
public final class SolanaWebSocketClient
|
||||||
|
extends WebSocketListener
|
||||||
|
implements AutoCloseable {
|
||||||
|
|
||||||
|
private final String websocketUrl;
|
||||||
|
private final String programId;
|
||||||
|
private final String commitment;
|
||||||
|
|
||||||
|
private final ObjectMapper mapper =
|
||||||
|
new ObjectMapper();
|
||||||
|
|
||||||
|
private final OkHttpClient httpClient;
|
||||||
|
|
||||||
|
private final ScheduledExecutorService scheduler;
|
||||||
|
|
||||||
|
private final AtomicBoolean closed =
|
||||||
|
new AtomicBoolean(false);
|
||||||
|
|
||||||
|
private final AtomicInteger reconnectAttempt =
|
||||||
|
new AtomicInteger(0);
|
||||||
|
|
||||||
|
private final AtomicBoolean everSubscribed =
|
||||||
|
new AtomicBoolean(false);
|
||||||
|
|
||||||
|
private volatile WebSocket webSocket;
|
||||||
|
|
||||||
|
private volatile AccountUpdateListener
|
||||||
|
accountUpdateListener;
|
||||||
|
|
||||||
|
private volatile ConnectionListener
|
||||||
|
connectionListener;
|
||||||
|
|
||||||
|
public SolanaWebSocketClient(
|
||||||
|
String websocketUrl,
|
||||||
|
String programId,
|
||||||
|
String commitment
|
||||||
|
) {
|
||||||
|
this.websocketUrl = websocketUrl;
|
||||||
|
this.programId = programId;
|
||||||
|
this.commitment = commitment;
|
||||||
|
|
||||||
|
this.httpClient =
|
||||||
|
new OkHttpClient.Builder()
|
||||||
|
.readTimeout(
|
||||||
|
Duration.ZERO
|
||||||
|
)
|
||||||
|
.pingInterval(
|
||||||
|
Duration.ofSeconds(20)
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.scheduler =
|
||||||
|
Executors
|
||||||
|
.newSingleThreadScheduledExecutor(
|
||||||
|
runnable -> {
|
||||||
|
|
||||||
|
Thread thread =
|
||||||
|
new Thread(
|
||||||
|
runnable,
|
||||||
|
"solana-rpc-reconnect"
|
||||||
|
);
|
||||||
|
|
||||||
|
thread.setDaemon(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start(
|
||||||
|
AccountUpdateListener accountUpdateListener,
|
||||||
|
ConnectionListener connectionListener
|
||||||
|
) {
|
||||||
|
|
||||||
|
this.accountUpdateListener =
|
||||||
|
accountUpdateListener;
|
||||||
|
|
||||||
|
this.connectionListener =
|
||||||
|
connectionListener;
|
||||||
|
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void connect() {
|
||||||
|
|
||||||
|
if (closed.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"Connecting to Solana WebSocket: "
|
||||||
|
+ websocketUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
Request request =
|
||||||
|
new Request.Builder()
|
||||||
|
.url(
|
||||||
|
websocketUrl
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.webSocket =
|
||||||
|
httpClient.newWebSocket(
|
||||||
|
request,
|
||||||
|
this
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onOpen(
|
||||||
|
WebSocket webSocket,
|
||||||
|
Response response
|
||||||
|
) {
|
||||||
|
|
||||||
|
reconnectAttempt.set(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"WebSocket connected"
|
||||||
|
);
|
||||||
|
|
||||||
|
sendProgramSubscribe(
|
||||||
|
webSocket
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendProgramSubscribe(
|
||||||
|
WebSocket webSocket
|
||||||
|
) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
Map<String, Object> request =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc",
|
||||||
|
"2.0",
|
||||||
|
"id",
|
||||||
|
1,
|
||||||
|
"method",
|
||||||
|
"programSubscribe",
|
||||||
|
"params",
|
||||||
|
List.of(
|
||||||
|
programId,
|
||||||
|
Map.of(
|
||||||
|
"encoding",
|
||||||
|
"base64",
|
||||||
|
"commitment",
|
||||||
|
commitment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
String payload =
|
||||||
|
mapper.writeValueAsString(
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!webSocket.send(
|
||||||
|
payload
|
||||||
|
)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"WebSocket rejected subscription request"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"Subscription request sent for program: "
|
||||||
|
+ programId
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (Exception exception) {
|
||||||
|
|
||||||
|
System.err.println(
|
||||||
|
"Failed to send subscription request: "
|
||||||
|
+ exception.getMessage()
|
||||||
|
);
|
||||||
|
|
||||||
|
webSocket.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(
|
||||||
|
WebSocket webSocket,
|
||||||
|
String text
|
||||||
|
) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
mapper.readTree(
|
||||||
|
text
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
root.has("id")
|
||||||
|
&& root.has("result")
|
||||||
|
&& root.get("id").asInt() == 1
|
||||||
|
) {
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"Subscribed successfully. Subscription id: "
|
||||||
|
+ root
|
||||||
|
.get("result")
|
||||||
|
.asText()
|
||||||
|
);
|
||||||
|
|
||||||
|
boolean firstConnection =
|
||||||
|
everSubscribed
|
||||||
|
.compareAndSet(
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
ConnectionListener listener =
|
||||||
|
connectionListener;
|
||||||
|
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onConnected(
|
||||||
|
firstConnection
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.has("error")) {
|
||||||
|
|
||||||
|
System.err.println(
|
||||||
|
"Solana RPC error: "
|
||||||
|
+ root.get("error")
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!"programNotification"
|
||||||
|
.equals(
|
||||||
|
root
|
||||||
|
.path("method")
|
||||||
|
.asText()
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgramAccountUpdate update =
|
||||||
|
parseProgramNotification(
|
||||||
|
root
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.printf(
|
||||||
|
"Account update: %s, slot=%d, base64Chars=%d%n",
|
||||||
|
update.address(),
|
||||||
|
update.slot(),
|
||||||
|
update.dataBase64().length()
|
||||||
|
);
|
||||||
|
|
||||||
|
AccountUpdateListener listener =
|
||||||
|
accountUpdateListener;
|
||||||
|
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onAccountUpdate(
|
||||||
|
update
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception exception) {
|
||||||
|
|
||||||
|
System.err.println(
|
||||||
|
"Failed to process WebSocket message: "
|
||||||
|
+ exception.getMessage()
|
||||||
|
);
|
||||||
|
|
||||||
|
System.err.println(
|
||||||
|
"Raw message: "
|
||||||
|
+ text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProgramAccountUpdate parseProgramNotification(
|
||||||
|
JsonNode root
|
||||||
|
) {
|
||||||
|
|
||||||
|
JsonNode result =
|
||||||
|
root
|
||||||
|
.path("params")
|
||||||
|
.path("result");
|
||||||
|
|
||||||
|
JsonNode context =
|
||||||
|
result.path("context");
|
||||||
|
|
||||||
|
JsonNode value =
|
||||||
|
result.path("value");
|
||||||
|
|
||||||
|
JsonNode account =
|
||||||
|
value.path("account");
|
||||||
|
|
||||||
|
JsonNode data =
|
||||||
|
account.path("data");
|
||||||
|
|
||||||
|
if (!data.isArray()
|
||||||
|
|| data.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Unexpected account.data format"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ProgramAccountUpdate(
|
||||||
|
requiredText(
|
||||||
|
value,
|
||||||
|
"pubkey"
|
||||||
|
),
|
||||||
|
requiredText(
|
||||||
|
account,
|
||||||
|
"owner"
|
||||||
|
),
|
||||||
|
requiredLong(
|
||||||
|
account,
|
||||||
|
"lamports"
|
||||||
|
),
|
||||||
|
requiredLong(
|
||||||
|
context,
|
||||||
|
"slot"
|
||||||
|
),
|
||||||
|
data.get(0).asText(),
|
||||||
|
account.path(
|
||||||
|
"executable"
|
||||||
|
).asBoolean(false),
|
||||||
|
account.hasNonNull(
|
||||||
|
"rentEpoch"
|
||||||
|
)
|
||||||
|
? account
|
||||||
|
.get("rentEpoch")
|
||||||
|
.asLong()
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requiredText(
|
||||||
|
JsonNode node,
|
||||||
|
String field
|
||||||
|
) {
|
||||||
|
|
||||||
|
JsonNode value =
|
||||||
|
node.get(field);
|
||||||
|
|
||||||
|
if (value == null
|
||||||
|
|| value.isNull()
|
||||||
|
|| value.asText().isBlank()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Missing field: "
|
||||||
|
+ field
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long requiredLong(
|
||||||
|
JsonNode node,
|
||||||
|
String field
|
||||||
|
) {
|
||||||
|
|
||||||
|
JsonNode value =
|
||||||
|
node.get(field);
|
||||||
|
|
||||||
|
if (value == null
|
||||||
|
|| !value.isNumber()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Missing numeric field: "
|
||||||
|
+ field
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.asLong();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClosed(
|
||||||
|
WebSocket webSocket,
|
||||||
|
int code,
|
||||||
|
String reason
|
||||||
|
) {
|
||||||
|
|
||||||
|
System.out.printf(
|
||||||
|
"WebSocket closed. code=%d reason=%s%n",
|
||||||
|
code,
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
|
||||||
|
notifyDisconnected(
|
||||||
|
new IllegalStateException(
|
||||||
|
"WebSocket closed: "
|
||||||
|
+ reason
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(
|
||||||
|
WebSocket webSocket,
|
||||||
|
Throwable throwable,
|
||||||
|
Response response
|
||||||
|
) {
|
||||||
|
|
||||||
|
System.err.println(
|
||||||
|
"WebSocket failure: "
|
||||||
|
+ throwable.getMessage()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response != null) {
|
||||||
|
System.err.println(
|
||||||
|
"HTTP status: "
|
||||||
|
+ response.code()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyDisconnected(
|
||||||
|
throwable
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyDisconnected(
|
||||||
|
Throwable cause
|
||||||
|
) {
|
||||||
|
|
||||||
|
ConnectionListener listener =
|
||||||
|
connectionListener;
|
||||||
|
|
||||||
|
if (listener != null) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
listener.onDisconnected(
|
||||||
|
cause
|
||||||
|
);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
System.err.println(
|
||||||
|
"Connection listener failed: "
|
||||||
|
+ exception.getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleReconnect() {
|
||||||
|
|
||||||
|
if (closed.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int attempt =
|
||||||
|
reconnectAttempt
|
||||||
|
.incrementAndGet();
|
||||||
|
|
||||||
|
long delaySeconds =
|
||||||
|
switch (
|
||||||
|
Math.min(
|
||||||
|
attempt,
|
||||||
|
5
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
case 1 -> 1;
|
||||||
|
case 2 -> 2;
|
||||||
|
case 3 -> 5;
|
||||||
|
case 4 -> 10;
|
||||||
|
default -> 30;
|
||||||
|
};
|
||||||
|
|
||||||
|
System.out.printf(
|
||||||
|
"Reconnect scheduled in %d seconds (attempt %d)%n",
|
||||||
|
delaySeconds,
|
||||||
|
attempt
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduler.schedule(
|
||||||
|
this::connect,
|
||||||
|
delaySeconds,
|
||||||
|
TimeUnit.SECONDS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
|
||||||
|
if (!closed.compareAndSet(
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WebSocket socket =
|
||||||
|
this.webSocket;
|
||||||
|
|
||||||
|
if (socket != null) {
|
||||||
|
socket.close(
|
||||||
|
1000,
|
||||||
|
"Application shutdown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduler.shutdownNow();
|
||||||
|
|
||||||
|
httpClient
|
||||||
|
.dispatcher()
|
||||||
|
.executorService()
|
||||||
|
.shutdown();
|
||||||
|
|
||||||
|
httpClient
|
||||||
|
.connectionPool()
|
||||||
|
.evictAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package sync.storage;
|
||||||
|
|
||||||
|
import sync.core.SyncCheckpoint;
|
||||||
|
import sync.core.SyncState;
|
||||||
|
import sync.model.ProgramAccountUpdate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface StorageRepository
|
||||||
|
extends AutoCloseable {
|
||||||
|
|
||||||
|
boolean hasAccounts()
|
||||||
|
throws Exception;
|
||||||
|
|
||||||
|
void upsertAccount(
|
||||||
|
ProgramAccountUpdate update
|
||||||
|
) throws Exception;
|
||||||
|
|
||||||
|
void upsertAccounts(
|
||||||
|
Iterable<ProgramAccountUpdate> updates
|
||||||
|
) throws Exception;
|
||||||
|
|
||||||
|
void deleteAccount(
|
||||||
|
String address
|
||||||
|
) throws Exception;
|
||||||
|
|
||||||
|
void deleteAccounts(
|
||||||
|
Iterable<String> addresses
|
||||||
|
) throws Exception;
|
||||||
|
|
||||||
|
boolean accountExists(
|
||||||
|
String address
|
||||||
|
) throws Exception;
|
||||||
|
|
||||||
|
List<String> findExistingAddresses(
|
||||||
|
Iterable<String> addresses
|
||||||
|
) throws Exception;
|
||||||
|
|
||||||
|
SyncCheckpoint getSyncCheckpoint()
|
||||||
|
throws Exception;
|
||||||
|
|
||||||
|
void updateSyncState(
|
||||||
|
SyncState state
|
||||||
|
) throws Exception;
|
||||||
|
|
||||||
|
void updateLastObservedSlot(
|
||||||
|
long slot
|
||||||
|
) throws Exception;
|
||||||
|
|
||||||
|
void updateLastReconciledSlot(
|
||||||
|
long slot
|
||||||
|
) throws Exception;
|
||||||
|
|
||||||
|
void updateLastFullSyncSlot(
|
||||||
|
long slot
|
||||||
|
) throws Exception;
|
||||||
|
}
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
package sync.storage.sqlite;
|
||||||
|
|
||||||
|
import sync.core.SyncCheckpoint;
|
||||||
|
import sync.core.SyncState;
|
||||||
|
import sync.model.ProgramAccountUpdate;
|
||||||
|
import sync.storage.StorageRepository;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class SQLiteStorageRepository
|
||||||
|
implements StorageRepository {
|
||||||
|
|
||||||
|
private final Connection connection;
|
||||||
|
|
||||||
|
public SQLiteStorageRepository(
|
||||||
|
Path databasePath
|
||||||
|
) throws SQLException, IOException {
|
||||||
|
|
||||||
|
Path absolutePath =
|
||||||
|
databasePath.toAbsolutePath();
|
||||||
|
|
||||||
|
Path parent =
|
||||||
|
absolutePath.getParent();
|
||||||
|
|
||||||
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connection =
|
||||||
|
DriverManager.getConnection(
|
||||||
|
"jdbc:sqlite:" + absolutePath
|
||||||
|
);
|
||||||
|
|
||||||
|
configure();
|
||||||
|
migrate();
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"Database initialized: " + absolutePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configure()
|
||||||
|
throws SQLException {
|
||||||
|
|
||||||
|
try (Statement statement =
|
||||||
|
connection.createStatement()) {
|
||||||
|
|
||||||
|
statement.execute(
|
||||||
|
"PRAGMA journal_mode=WAL"
|
||||||
|
);
|
||||||
|
|
||||||
|
statement.execute(
|
||||||
|
"PRAGMA synchronous=NORMAL"
|
||||||
|
);
|
||||||
|
|
||||||
|
statement.execute(
|
||||||
|
"PRAGMA busy_timeout=5000"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void migrate()
|
||||||
|
throws SQLException {
|
||||||
|
|
||||||
|
try (Statement statement =
|
||||||
|
connection.createStatement()) {
|
||||||
|
|
||||||
|
statement.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS program_accounts (" +
|
||||||
|
"address TEXT PRIMARY KEY," +
|
||||||
|
"owner TEXT NOT NULL," +
|
||||||
|
"lamports INTEGER NOT NULL," +
|
||||||
|
"slot INTEGER NOT NULL," +
|
||||||
|
"data_base64 TEXT NOT NULL," +
|
||||||
|
"executable INTEGER NOT NULL," +
|
||||||
|
"rent_epoch INTEGER," +
|
||||||
|
"updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP" +
|
||||||
|
")"
|
||||||
|
);
|
||||||
|
|
||||||
|
statement.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_program_accounts_slot " +
|
||||||
|
"ON program_accounts(slot)"
|
||||||
|
);
|
||||||
|
|
||||||
|
statement.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS sync_state (" +
|
||||||
|
"id INTEGER PRIMARY KEY CHECK (id = 1)," +
|
||||||
|
"state TEXT NOT NULL," +
|
||||||
|
"last_observed_slot INTEGER," +
|
||||||
|
"last_reconciled_slot INTEGER," +
|
||||||
|
"last_full_sync_slot INTEGER," +
|
||||||
|
"last_sync_at TEXT," +
|
||||||
|
"updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP" +
|
||||||
|
")"
|
||||||
|
);
|
||||||
|
|
||||||
|
statement.execute(
|
||||||
|
"INSERT OR IGNORE INTO sync_state " +
|
||||||
|
"(id, state) VALUES (1, 'EMPTY')"
|
||||||
|
);
|
||||||
|
|
||||||
|
ensureColumnExists(
|
||||||
|
statement,
|
||||||
|
"sync_state",
|
||||||
|
"last_observed_slot",
|
||||||
|
"INTEGER"
|
||||||
|
);
|
||||||
|
|
||||||
|
ensureColumnExists(
|
||||||
|
statement,
|
||||||
|
"sync_state",
|
||||||
|
"last_reconciled_slot",
|
||||||
|
"INTEGER"
|
||||||
|
);
|
||||||
|
|
||||||
|
ensureColumnExists(
|
||||||
|
statement,
|
||||||
|
"sync_state",
|
||||||
|
"last_full_sync_slot",
|
||||||
|
"INTEGER"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnExists(
|
||||||
|
statement,
|
||||||
|
"sync_state",
|
||||||
|
"last_processed_slot"
|
||||||
|
)) {
|
||||||
|
|
||||||
|
statement.execute(
|
||||||
|
"UPDATE sync_state SET " +
|
||||||
|
"last_observed_slot = COALESCE(" +
|
||||||
|
"last_observed_slot, last_processed_slot), " +
|
||||||
|
"last_reconciled_slot = COALESCE(" +
|
||||||
|
"last_reconciled_slot, last_processed_slot)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureColumnExists(
|
||||||
|
Statement statement,
|
||||||
|
String table,
|
||||||
|
String column,
|
||||||
|
String type
|
||||||
|
) throws SQLException {
|
||||||
|
|
||||||
|
if (!columnExists(
|
||||||
|
statement,
|
||||||
|
table,
|
||||||
|
column
|
||||||
|
)) {
|
||||||
|
|
||||||
|
statement.execute(
|
||||||
|
"ALTER TABLE "
|
||||||
|
+ table
|
||||||
|
+ " ADD COLUMN "
|
||||||
|
+ column
|
||||||
|
+ " "
|
||||||
|
+ type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean columnExists(
|
||||||
|
Statement statement,
|
||||||
|
String table,
|
||||||
|
String column
|
||||||
|
) throws SQLException {
|
||||||
|
|
||||||
|
try (ResultSet resultSet =
|
||||||
|
statement.executeQuery(
|
||||||
|
"PRAGMA table_info("
|
||||||
|
+ table
|
||||||
|
+ ")"
|
||||||
|
)) {
|
||||||
|
|
||||||
|
while (resultSet.next()) {
|
||||||
|
|
||||||
|
if (column.equals(
|
||||||
|
resultSet.getString("name")
|
||||||
|
)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void upsertAccount(
|
||||||
|
ProgramAccountUpdate update
|
||||||
|
) throws SQLException {
|
||||||
|
|
||||||
|
String sql =
|
||||||
|
"INSERT INTO program_accounts (" +
|
||||||
|
"address, owner, lamports, slot, " +
|
||||||
|
"data_base64, executable, rent_epoch, updated_at" +
|
||||||
|
") VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) " +
|
||||||
|
|
||||||
|
"ON CONFLICT(address) DO UPDATE SET " +
|
||||||
|
"owner = excluded.owner, " +
|
||||||
|
"lamports = excluded.lamports, " +
|
||||||
|
"slot = excluded.slot, " +
|
||||||
|
"data_base64 = excluded.data_base64, " +
|
||||||
|
"executable = excluded.executable, " +
|
||||||
|
"rent_epoch = excluded.rent_epoch, " +
|
||||||
|
"updated_at = CURRENT_TIMESTAMP " +
|
||||||
|
|
||||||
|
"WHERE excluded.slot >= program_accounts.slot";
|
||||||
|
|
||||||
|
try (PreparedStatement statement =
|
||||||
|
connection.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
statement.setString(1, update.address());
|
||||||
|
statement.setString(2, update.owner());
|
||||||
|
statement.setLong(3, update.lamports());
|
||||||
|
statement.setLong(4, update.slot());
|
||||||
|
statement.setString(5, update.dataBase64());
|
||||||
|
statement.setInt(
|
||||||
|
6,
|
||||||
|
update.executable()
|
||||||
|
? 1
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (update.rentEpoch() == null) {
|
||||||
|
statement.setNull(
|
||||||
|
7,
|
||||||
|
Types.BIGINT
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
statement.setLong(
|
||||||
|
7,
|
||||||
|
update.rentEpoch()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
statement.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void upsertAccounts(
|
||||||
|
Iterable<ProgramAccountUpdate> updates
|
||||||
|
) throws Exception {
|
||||||
|
|
||||||
|
for (ProgramAccountUpdate update : updates) {
|
||||||
|
upsertAccount(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void deleteAccount(
|
||||||
|
String address
|
||||||
|
) throws SQLException {
|
||||||
|
|
||||||
|
try (PreparedStatement statement =
|
||||||
|
connection.prepareStatement(
|
||||||
|
"DELETE FROM program_accounts " +
|
||||||
|
"WHERE address = ?"
|
||||||
|
)) {
|
||||||
|
|
||||||
|
statement.setString(
|
||||||
|
1,
|
||||||
|
address
|
||||||
|
);
|
||||||
|
|
||||||
|
statement.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void deleteAccounts(
|
||||||
|
Iterable<String> addresses
|
||||||
|
) throws Exception {
|
||||||
|
|
||||||
|
for (String address : addresses) {
|
||||||
|
deleteAccount(address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized boolean accountExists(
|
||||||
|
String address
|
||||||
|
) throws SQLException {
|
||||||
|
|
||||||
|
try (PreparedStatement statement =
|
||||||
|
connection.prepareStatement(
|
||||||
|
"SELECT 1 " +
|
||||||
|
"FROM program_accounts " +
|
||||||
|
"WHERE address = ? " +
|
||||||
|
"LIMIT 1"
|
||||||
|
)) {
|
||||||
|
|
||||||
|
statement.setString(
|
||||||
|
1,
|
||||||
|
address
|
||||||
|
);
|
||||||
|
|
||||||
|
try (ResultSet resultSet =
|
||||||
|
statement.executeQuery()) {
|
||||||
|
|
||||||
|
return resultSet.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized List<String> findExistingAddresses(
|
||||||
|
Iterable<String> addresses
|
||||||
|
) throws SQLException {
|
||||||
|
|
||||||
|
List<String> existing =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
try (PreparedStatement statement =
|
||||||
|
connection.prepareStatement(
|
||||||
|
"SELECT 1 " +
|
||||||
|
"FROM program_accounts " +
|
||||||
|
"WHERE address = ? " +
|
||||||
|
"LIMIT 1"
|
||||||
|
)) {
|
||||||
|
|
||||||
|
for (String address : addresses) {
|
||||||
|
|
||||||
|
statement.setString(
|
||||||
|
1,
|
||||||
|
address
|
||||||
|
);
|
||||||
|
|
||||||
|
try (ResultSet resultSet =
|
||||||
|
statement.executeQuery()) {
|
||||||
|
|
||||||
|
if (resultSet.next()) {
|
||||||
|
existing.add(
|
||||||
|
address
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized boolean hasAccounts()
|
||||||
|
throws SQLException {
|
||||||
|
|
||||||
|
try (
|
||||||
|
Statement statement =
|
||||||
|
connection.createStatement();
|
||||||
|
|
||||||
|
ResultSet resultSet =
|
||||||
|
statement.executeQuery(
|
||||||
|
"SELECT EXISTS(" +
|
||||||
|
"SELECT 1 " +
|
||||||
|
"FROM program_accounts " +
|
||||||
|
"LIMIT 1" +
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
|
||||||
|
return resultSet.next()
|
||||||
|
&& resultSet.getInt(1) == 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized SyncCheckpoint getSyncCheckpoint()
|
||||||
|
throws SQLException {
|
||||||
|
|
||||||
|
try (
|
||||||
|
Statement statement =
|
||||||
|
connection.createStatement();
|
||||||
|
|
||||||
|
ResultSet resultSet =
|
||||||
|
statement.executeQuery(
|
||||||
|
"SELECT " +
|
||||||
|
"state, " +
|
||||||
|
"last_observed_slot, " +
|
||||||
|
"last_reconciled_slot, " +
|
||||||
|
"last_full_sync_slot " +
|
||||||
|
"FROM sync_state " +
|
||||||
|
"WHERE id = 1"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (!resultSet.next()) {
|
||||||
|
|
||||||
|
return new SyncCheckpoint(
|
||||||
|
SyncState.EMPTY,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SyncCheckpoint(
|
||||||
|
SyncState.valueOf(
|
||||||
|
resultSet.getString(
|
||||||
|
"state"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
getNullableLong(
|
||||||
|
resultSet,
|
||||||
|
"last_observed_slot"
|
||||||
|
),
|
||||||
|
getNullableLong(
|
||||||
|
resultSet,
|
||||||
|
"last_reconciled_slot"
|
||||||
|
),
|
||||||
|
getNullableLong(
|
||||||
|
resultSet,
|
||||||
|
"last_full_sync_slot"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long getNullableLong(
|
||||||
|
ResultSet resultSet,
|
||||||
|
String column
|
||||||
|
) throws SQLException {
|
||||||
|
|
||||||
|
long value =
|
||||||
|
resultSet.getLong(
|
||||||
|
column
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resultSet.wasNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void updateSyncState(
|
||||||
|
SyncState state
|
||||||
|
) throws SQLException {
|
||||||
|
|
||||||
|
try (PreparedStatement statement =
|
||||||
|
connection.prepareStatement(
|
||||||
|
"UPDATE sync_state " +
|
||||||
|
"SET state = ?, " +
|
||||||
|
"updated_at = CURRENT_TIMESTAMP " +
|
||||||
|
"WHERE id = 1"
|
||||||
|
)) {
|
||||||
|
|
||||||
|
statement.setString(
|
||||||
|
1,
|
||||||
|
state.name()
|
||||||
|
);
|
||||||
|
|
||||||
|
statement.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void updateLastObservedSlot(
|
||||||
|
long slot
|
||||||
|
) throws SQLException {
|
||||||
|
|
||||||
|
updateMaxSlot(
|
||||||
|
"last_observed_slot",
|
||||||
|
slot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void updateLastReconciledSlot(
|
||||||
|
long slot
|
||||||
|
) throws SQLException {
|
||||||
|
|
||||||
|
updateMaxSlot(
|
||||||
|
"last_reconciled_slot",
|
||||||
|
slot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateMaxSlot(
|
||||||
|
String column,
|
||||||
|
long slot
|
||||||
|
) throws SQLException {
|
||||||
|
|
||||||
|
String sql =
|
||||||
|
"UPDATE sync_state " +
|
||||||
|
"SET " + column + " = " +
|
||||||
|
"CASE " +
|
||||||
|
"WHEN " + column + " IS NULL " +
|
||||||
|
"OR ? > " + column + " " +
|
||||||
|
"THEN ? " +
|
||||||
|
"ELSE " + column + " " +
|
||||||
|
"END, " +
|
||||||
|
"last_sync_at = CURRENT_TIMESTAMP, " +
|
||||||
|
"updated_at = CURRENT_TIMESTAMP " +
|
||||||
|
"WHERE id = 1";
|
||||||
|
|
||||||
|
try (PreparedStatement statement =
|
||||||
|
connection.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
statement.setLong(
|
||||||
|
1,
|
||||||
|
slot
|
||||||
|
);
|
||||||
|
|
||||||
|
statement.setLong(
|
||||||
|
2,
|
||||||
|
slot
|
||||||
|
);
|
||||||
|
|
||||||
|
statement.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void updateLastFullSyncSlot(
|
||||||
|
long slot
|
||||||
|
) throws SQLException {
|
||||||
|
|
||||||
|
try (PreparedStatement statement =
|
||||||
|
connection.prepareStatement(
|
||||||
|
"UPDATE sync_state " +
|
||||||
|
"SET last_full_sync_slot = ?, " +
|
||||||
|
"updated_at = CURRENT_TIMESTAMP " +
|
||||||
|
"WHERE id = 1"
|
||||||
|
)) {
|
||||||
|
|
||||||
|
statement.setLong(
|
||||||
|
1,
|
||||||
|
slot
|
||||||
|
);
|
||||||
|
|
||||||
|
statement.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close()
|
||||||
|
throws SQLException {
|
||||||
|
|
||||||
|
connection.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user