Compare commits

..
Author SHA256 Message Date
AidarKC be696d116b Аввив 2.0 - работает, заливка!! 2026-09-21 12:49:33 +03:00
90 changed files with 2411 additions and 14938 deletions
+29
View File
@@ -0,0 +1,29 @@
# SHiNE ANS-104 `test5590` patch
Этот архив содержит **только новые/изменённые файлы** плюс `DELETE_FILES.txt`.
## Что меняется
- Frame v1 без поддержки старого формата.
- Один пользовательский block = готовый Ed25519 ANS-104 DataItem.
- Test tag: `App=test5590`; channel tag: `c=<canonical slug>`.
- SHiNE `prevHash` остаётся `SHA-256(FrameV1Bytes)` и не зависит от Arweave ID.
- PostgreSQL — единственное локальное хранилище пользовательских blocks.
- Старый custom SHINE-ARCHIVE и filesystem blockchain storage удаляются.
- Каждые ~15 минут локальные pending DataItems собираются в standard ANS-104 bundle и идут через существующий native Arweave L1 transport.
- Другие серверы находят `App=test5590`, импортируют и не публикуют повторно.
- `ESP32/**` **не изменён**.
## Состав
- изменено/добавлено файлов: 46
- удалить файлов: 41
Применение и проверки: `docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md`.
## Проверки при сборке patch
- `node --check` для изменённых production JS: OK.
- `diff -qr` каталога `ESP32` относительно исходного архива: без изменений.
- Active Java refs на удалённые SHINE-ARCHIVE/file-recovery classes: отсутствуют.
- `./gradlew testClasses --offline --no-daemon`: не запущен до компиляции, потому что wrapper требует скачать Gradle 8.14 с `services.gradle.org`, а рабочая среда не имеет внешнего сетевого доступа. Полный Gradle test обязателен после применения patch в dev-среде.
@@ -0,0 +1,103 @@
package server.archive;
import blockchain.Ans104DataItem;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/** Minimal ANS-104 binary bundle writer/reader. */
public final class Ans104Bundle {
public record Entry(byte[] id32, byte[] rawDataItem) {}
private Ans104Bundle() {}
public static byte[] write(List<byte[]> rawItems) {
if (rawItems == null || rawItems.isEmpty()) throw new IllegalArgumentException("ANS-104 bundle is empty");
if (rawItems.size() > 1_000_000) throw new IllegalArgumentException("Too many ANS-104 items");
List<Ans104DataItem> parsed = new ArrayList<>(rawItems.size());
long total = 32L + 64L * rawItems.size();
for (byte[] raw : rawItems) {
Ans104DataItem item = new Ans104DataItem(raw);
if (!item.verifySignature()) throw new IllegalArgumentException("Invalid ANS-104 DataItem signature");
parsed.add(item);
total = Math.addExact(total, raw.length);
}
if (total > Integer.MAX_VALUE) throw new IllegalArgumentException("Bundle too large for in-memory publisher: " + total);
ByteArrayOutputStream out = new ByteArrayOutputStream((int) total);
out.writeBytes(uint256LE(rawItems.size()));
for (int i = 0; i < rawItems.size(); i++) {
out.writeBytes(uint256LE(rawItems.get(i).length));
out.writeBytes(parsed.get(i).id32());
}
for (byte[] raw : rawItems) out.writeBytes(raw);
return out.toByteArray();
}
public static List<Entry> read(byte[] bundle, int maxItems) {
if (bundle == null || bundle.length < 32) throw new IllegalArgumentException("ANS-104 bundle too short");
long countLong = readUint256AsLong(bundle, 0);
if (countLong <= 0 || countLong > Math.max(1, maxItems)) {
throw new IllegalArgumentException("Bad ANS-104 bundle item count: " + countLong);
}
int count = (int) countLong;
long headerLong = 32L + 64L * count;
if (headerLong > bundle.length) throw new IllegalArgumentException("ANS-104 bundle header truncated");
int header = (int) headerLong;
long[] sizes = new long[count];
byte[][] ids = new byte[count][];
int hp = 32;
long payloadBytes = 0;
for (int i = 0; i < count; i++) {
sizes[i] = readUint256AsLong(bundle, hp); hp += 32;
ids[i] = Arrays.copyOfRange(bundle, hp, hp + 32); hp += 32;
if (sizes[i] <= 0 || sizes[i] > Integer.MAX_VALUE) throw new IllegalArgumentException("Bad DataItem size: " + sizes[i]);
payloadBytes = Math.addExact(payloadBytes, sizes[i]);
}
if (headerLong + payloadBytes != bundle.length) {
throw new IllegalArgumentException("ANS-104 bundle size mismatch: header=" + headerLong + " payload=" + payloadBytes + " actual=" + bundle.length);
}
List<Entry> out = new ArrayList<>(count);
int p = header;
for (int i = 0; i < count; i++) {
int len = (int) sizes[i];
byte[] raw = Arrays.copyOfRange(bundle, p, p + len);
p += len;
Ans104DataItem item = new Ans104DataItem(raw);
if (!Arrays.equals(ids[i], item.id32())) throw new IllegalArgumentException("Bundle entry ID mismatch at index " + i);
out.add(new Entry(ids[i], raw));
}
return out;
}
public static byte[] find(byte[] bundle, byte[] wantedId32, int maxItems) {
if (wantedId32 == null || wantedId32.length != 32) throw new IllegalArgumentException("wanted ID must be 32 bytes");
for (Entry e : read(bundle, maxItems)) {
if (Arrays.equals(e.id32(), wantedId32)) return e.rawDataItem();
}
return null;
}
/** ANS-104 bundle integers are 32-byte unsigned little-endian values. */
static byte[] uint256LE(long value) {
if (value < 0) throw new IllegalArgumentException("negative uint256");
byte[] out = new byte[32];
long v = value;
for (int i = 0; i < 8; i++) { out[i] = (byte) (v & 0xff); v >>>= 8; }
return out;
}
static long readUint256AsLong(byte[] data, int offset) {
if (offset < 0 || offset + 32 > data.length) throw new IllegalArgumentException("uint256 truncated");
for (int i = offset + 8; i < offset + 32; i++) {
if (data[i] != 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
}
long v = 0;
for (int i = 7; i >= 0; i--) v = (v << 8) | (data[offset + i] & 0xffL);
if (v < 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
return v;
}
}
@@ -1,56 +0,0 @@
package server.archive;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
/** Единые локальные имена больших архивных файлов. */
public final class ArchiveFileNames {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yy");
private ArchiveFileNames() {}
/**
* Временное имя полностью собранного файла до появления Arweave TX ID.
* Пример: archive01.00001.11.09.26.tmp.SHiNE-archive
*/
public static String pendingFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
return baseName(login, bigBlockNumber, createdAtMs, zone) + ".tmp.SHiNE-archive";
}
/**
* Постоянное имя после успешной загрузки в Arweave.
* Пример: archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive
*/
public static String uploadedFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
if (txId == null || txId.length != 32) throw new IllegalArgumentException("Arweave TX ID должен быть 32 bytes");
String tx = Base64.getUrlEncoder().withoutPadding().encodeToString(txId);
return baseName(login, bigBlockNumber, createdAtMs, zone) + "." + tx + ".SHiNE-archive";
}
public static Path pendingFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
return archiveDir.resolve(pendingFileName(login, bigBlockNumber, createdAtMs, zone));
}
public static Path uploadedFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
return archiveDir.resolve(uploadedFileName(login, bigBlockNumber, createdAtMs, zone, txId));
}
private static String baseName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
if (bigBlockNumber < 0 || bigBlockNumber > 0xffff_ffffL) {
throw new IllegalArgumentException("Номер большого блока вне u32: " + bigBlockNumber);
}
String safeLogin = sanitize(login);
String number = String.format("%05d", bigBlockNumber);
String date = DATE.format(Instant.ofEpochMilli(createdAtMs).atZone(zone == null ? ZoneId.systemDefault() : zone));
return safeLogin + "." + number + "." + date;
}
static String sanitize(String login) {
String s = login == null ? "server" : login.trim();
if (s.isEmpty()) s = "server";
return s.replaceAll("[^A-Za-z0-9._-]", "_");
}
}
@@ -1,37 +0,0 @@
package server.archive;
import utils.config.AppConfig;
import java.nio.file.Path;
import java.util.*;
/** Настройки чтения архивов других доверенных SHiNE-серверов. Пустой whitelist = импорт выключен. */
public record ArchiveImportConfig(
Set<String> allowedPublishers,
int intervalMinutes,
String arweaveGateway,
Path workDir
) {
public static ArchiveImportConfig load() {
AppConfig c = AppConfig.getInstance();
Set<String> approved = new LinkedHashSet<>();
String raw = c.getParam("archive.import.allowedPublishers");
if (raw != null) {
for (String item : raw.split(",")) {
String login = item.trim().toLowerCase(Locale.ROOT);
if (!login.isEmpty()) approved.add(login);
}
}
String gateway = c.getParam("archive.arweave.gateway");
if (gateway == null || gateway.isBlank()) gateway = "https://arweave.net";
String dir = c.getParam("archive.import.workDir");
if (dir == null || dir.isBlank()) dir = "data/archive-import";
return new ArchiveImportConfig(
Set.copyOf(approved),
Math.max(1, c.getInt("archive.import.intervalMinutes", 60)),
gateway.trim(),
Path.of(dir));
}
public boolean enabled() { return !allowedPublishers.isEmpty(); }
}
@@ -1,54 +0,0 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.*;
/**
* Фоновая проверка уже синхронизированной локальной таблицы User PDA.
* Solana отдельно здесь не опрашивается: первый запуск через 10 секунд после старта,
* затем по умолчанию раз в час.
*/
public final class ArchiveImportScheduler {
private static final Logger log = LoggerFactory.getLogger(ArchiveImportScheduler.class);
private static ScheduledExecutorService executor;
private ArchiveImportScheduler() {}
public static synchronized void startOrLog() {
if (executor != null) return;
ArchiveImportConfig cfg = ArchiveImportConfig.load();
if (!cfg.enabled()) {
log.info("Archive importer выключен: archive.import.allowedPublishers пуст");
return;
}
try {
ArchiveImportService service = new ArchiveImportService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "archive-importer");
t.setDaemon(true);
return t;
});
executor.scheduleWithFixedDelay(
() -> {
try { service.pollOnce(); }
catch (Exception e) { log.error("Archive importer cycle failed", e); }
},
10,
Math.multiplyExact((long) cfg.intervalMinutes(), 60L),
TimeUnit.SECONDS);
log.info("Archive importer включён. approvedPublishers={} interval={}min",
cfg.allowedPublishers(), cfg.intervalMinutes());
} catch (Exception e) {
log.error("Archive importer не запущен", e);
}
}
public static synchronized void close() {
if (executor != null) {
executor.shutdownNow();
executor = null;
}
}
}
@@ -1,229 +0,0 @@
package server.archive;
import blockchain.BchBlockEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import shine.db.archive.*;
import shine.db.dao.*;
import shine.db.entities.BlockchainStateEntry;
import java.io.IOException;
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
/** Импорт archive head только от серверов из server-side whitelist. */
public final class ArchiveImportService {
private static final Logger log = LoggerFactory.getLogger(ArchiveImportService.class);
private final ArchiveImportConfig cfg;
private final ArchiveImportDAO dao = ArchiveImportDAO.getInstance();
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
private final ShineArchiveReader reader = new ShineArchiveReader();
private final Net_AddBlock_Handler addBlockHandler = new Net_AddBlock_Handler();
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
public ArchiveImportService(ArchiveImportConfig cfg) throws IOException {
this.cfg = Objects.requireNonNull(cfg);
Files.createDirectories(cfg.workDir());
}
/**
* Никаких отдельных Solana RPC запросов здесь нет.
* Читаем только solana_user_pda_current, которую уже обновляет обычный Solana users sync.
*/
public void pollOnce() throws Exception {
if (!cfg.enabled()) return;
for (ArchivePublisherHead head : dao.listPendingPublisherHeads(cfg.allowedPublishers())) {
try {
importHead(head);
} catch (Exception e) {
log.error("Archive import publisher={} tx={} failed", head.login(), head.archiveHeadTxId(), e);
}
}
}
private void importHead(ArchivePublisherHead publisher) throws Exception {
String headTxId = publisher.archiveHeadTxId();
byte[] headHash = hex(publisher.archiveHeadHash());
// Если процесс успел импортировать head, но упал до установки archive_imported=true,
// следующий цикл просто завершает отметку без повторного скачивания всей истории.
if (headTxId.equals(publisher.lastImportedArchiveTxId())) {
if (!dao.markHeadImported(publisher.login(), headTxId)) {
log.info("Archive head changed while finalizing import marker publisher={} oldHead={}",
publisher.login(), headTxId);
}
return;
}
Path headFile = download(headTxId);
ShineArchiveReader.Document head;
try {
head = reader.readAndVerify(headFile, publisher.login(), headHash, publisher.rootKey());
} finally {
Files.deleteIfExists(headFile);
}
// FULL reference table позволяет догнать все archive blocks, пропущенные пока сервер был выключен.
List<ShineArchiveReader.Reference> previous = new ArrayList<>(head.references());
previous.sort(Comparator.comparingLong(ShineArchiveReader.Reference::bigBlockNumber));
int startIndex = findResumeIndex(previous, publisher.lastImportedArchiveTxId(), headTxId);
for (int i = startIndex; i < previous.size(); i++) {
ShineArchiveReader.Reference ref = previous.get(i);
importOne(publisher, ref.arweaveTxId(), ref.archiveHash());
if (!dao.advanceImportCursor(publisher.login(), headTxId, ref.arweaveTxId())) {
throw new IllegalStateException("Archive head changed during import for publisher " + publisher.login());
}
}
// Head уже скачан и проверен выше — второй раз его из Arweave не качаем.
importDocument(publisher, headTxId, head);
if (!dao.advanceImportCursor(publisher.login(), headTxId, headTxId)) {
throw new IllegalStateException("Archive head changed during final import for publisher " + publisher.login());
}
if (!dao.markHeadImported(publisher.login(), headTxId)) {
throw new IllegalStateException("Archive head changed before imported marker for publisher " + publisher.login());
}
log.info("Archive head fully imported publisher={} bigBlock={} tx={}",
publisher.login(), head.bigBlockNumber(), headTxId);
}
/**
* Возвращает индекс первого ещё не импортированного previous big block.
* Пустой cursor = новый сервер, импортируем всю FULL историю.
*/
private static int findResumeIndex(List<ShineArchiveReader.Reference> previous,
String lastImportedTxId,
String currentHeadTxId) {
String last = lastImportedTxId == null ? "" : lastImportedTxId.trim();
if (last.isEmpty()) return 0;
if (last.equals(currentHeadTxId)) return previous.size();
for (int i = 0; i < previous.size(); i++) {
if (last.equals(previous.get(i).arweaveTxId())) return i + 1;
}
throw new IllegalStateException(
"Последний импортированный archive TX не найден в FULL history текущего head: " + last);
}
private void importOne(ArchivePublisherHead publisher, String txId, byte[] expectedHash) throws Exception {
Path file = download(txId);
try {
ShineArchiveReader.Document doc = reader.readAndVerify(
file, publisher.login(), expectedHash, publisher.rootKey());
importDocument(publisher, txId, doc);
} finally {
Files.deleteIfExists(file);
}
}
private void importDocument(ArchivePublisherHead publisher, String txId,
ShineArchiveReader.Document doc) throws Exception {
for (ShineArchiveReader.Chunk chunk : doc.chunks()) {
processChunk(publisher.login(), txId, doc, chunk);
}
log.info("Archive imported publisher={} bigBlock={} tx={} chunks={}",
publisher.login(), doc.bigBlockNumber(), txId, doc.chunks().size());
}
private void processChunk(String publisherLogin, String txId,
ShineArchiveReader.Document doc,
ShineArchiveReader.Chunk chunk) throws Exception {
ReentrantLock lock = BlockchainLocks.lockFor(chunk.blockchainName());
lock.lock();
try {
ensureState(chunk.blockchainName());
BlockchainStateEntry state = stateDao.getByBlockchainName(chunk.blockchainName());
int localLast = state == null ? -1 : state.getLastBlockNumber();
long archiveLast = -1;
for (byte[] raw : chunk.rawRecords()) {
BchBlockEntry block = new BchBlockEntry(raw);
archiveLast = Integer.toUnsignedLong(block.blockNumber);
if (block.blockNumber <= localLast) {
byte[] localHash = blocksDao.getHashByNumber(chunk.blockchainName(), block.blockNumber);
if (!Arrays.equals(localHash, block.getHash32())) {
throw new IllegalStateException(
"Archive conflict " + chunk.blockchainName() + "#" + block.blockNumber);
}
continue;
}
if (block.blockNumber != localLast + 1) {
throw new IllegalStateException(
"Archive gap " + chunk.blockchainName() + ": local=" + localLast +
" next=" + block.blockNumber);
}
var result = addBlockHandler.addBlockFromArchive(chunk.blockchainName(), raw);
if (!result.ok()) {
throw new IllegalStateException(
"Archive AddBlock rejected " + chunk.blockchainName() + "#" +
block.blockNumber + ": " + result.reasonCode());
}
localLast = block.blockNumber;
}
if (archiveLast >= 0) {
dao.upsertLocation(new ArchiveBlockchainLocation(
chunk.blockchainName(), publisherLogin, txId, doc.archiveHash(), doc.bigBlockNumber(),
chunk.offset(), chunk.size(), archiveLast, System.currentTimeMillis()));
}
} finally {
lock.unlock();
}
}
private void ensureState(String blockchainName) throws Exception {
if (stateDao.getByBlockchainName(blockchainName) != null) return;
ArchiveImportDAO.BlockchainIdentity id = dao.getBlockchainIdentity(blockchainName);
if (id == null) throw new IllegalStateException("Нет User PDA для blockchain " + blockchainName);
BlockchainStateEntry s = new BlockchainStateEntry();
s.setBlockchainName(id.blockchainName());
s.setLogin(id.login());
s.setBlockchainKey(id.blockchainKey());
s.setSizeLimit(id.sizeLimit());
s.setFileSizeBytes(0);
s.setLastBlockNumber(-1);
s.setLastBlockHash(null);
s.setUpdatedAtMs(System.currentTimeMillis());
stateDao.insertIfMissing(s);
}
private Path download(String txId) throws Exception {
String gateway = cfg.arweaveGateway().replaceAll("/+$", "");
Path target = Files.createTempFile(cfg.workDir(), "archive-", ".download");
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/" + txId))
.timeout(Duration.ofMinutes(10))
.GET()
.build();
HttpResponse<Path> resp = http.send(req, HttpResponse.BodyHandlers.ofFile(target));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
Files.deleteIfExists(target);
throw new IOException("Arweave HTTP " + resp.statusCode() + " tx=" + txId);
}
return target;
}
private static byte[] hex(String value) {
String s = String.valueOf(value == null ? "" : value).trim();
if (s.length() != 64) throw new IllegalArgumentException("archive_head_hash должен быть HEX64");
byte[] out = new byte[32];
for (int i = 0; i < 32; i++) {
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
}
@@ -1,75 +0,0 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import sync.util.Base58Util;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
/** Читает Ed25519 seed32 из распространённых SHiNE/Solana форматов ключей. */
final class ArchiveKeyLoader {
private static final ObjectMapper JSON = new ObjectMapper();
private ArchiveKeyLoader() {}
static byte[] loadSeed32(Path path) throws IOException {
if (path == null) throw new IllegalArgumentException("Путь ключа не задан");
byte[] raw = Files.readAllBytes(path);
if (raw.length == 32) return raw.clone();
if (raw.length == 64) return first32(raw);
String text = new String(raw, StandardCharsets.UTF_8).trim();
if (text.isEmpty()) throw new IOException("Пустой key file: " + path);
// Solana keypair JSON: [64 чисел].
if (text.startsWith("[")) {
JsonNode arr = JSON.readTree(text);
if (!arr.isArray() || (arr.size() != 32 && arr.size() != 64)) {
throw new IOException("Ожидался JSON keypair на 32/64 байта: " + path);
}
byte[] bytes = new byte[arr.size()];
for (int i = 0; i < arr.size(); i++) {
int v = arr.get(i).asInt(-1);
if (v < 0 || v > 255) throw new IOException("Некорректный байт keypair: " + path);
bytes[i] = (byte) v;
}
return bytes.length == 32 ? bytes : first32(bytes);
}
// Base58 seed32 или Solana secret key 64 bytes.
try {
byte[] decoded = Base58Util.decode(text);
if (decoded.length == 32) return decoded;
if (decoded.length == 64) return first32(decoded);
} catch (IllegalArgumentException ignored) {
// Пробуем остальные текстовые форматы ниже.
}
// Base64 raw seed / keypair / Ed25519 PKCS8. В SHiNE PKCS8 seed находится в последних 32 байтах.
try {
byte[] decoded = Base64.getDecoder().decode(text.replaceAll("\\s+", ""));
if (decoded.length == 32) return decoded;
if (decoded.length == 64) return first32(decoded);
if (decoded.length > 32) {
byte[] seed = new byte[32];
System.arraycopy(decoded, decoded.length - 32, seed, 0, 32);
return seed;
}
} catch (IllegalArgumentException ignored) {
// Ниже выдадим понятную общую ошибку.
}
throw new IOException("Не удалось распознать Ed25519 key file: " + path
+ ". Поддерживаются raw seed32, raw/keypair64, Solana JSON, Base58 seed/keypair и Base64 PKCS8.");
}
private static byte[] first32(byte[] bytes) {
byte[] out = new byte[32];
System.arraycopy(bytes, 0, out, 0, 32);
return out;
}
}
@@ -1,98 +0,0 @@
package server.archive;
import utils.config.AppConfig;
import utils.config.SolanaProgramsConfig;
import java.nio.file.Path;
import java.time.LocalTime;
import java.time.ZoneId;
/** Настройки опционального архивного publisher-а. По умолчанию функция выключена. */
public record ArchivePublisherConfig(
boolean enabled,
LocalTime publishTime,
ZoneId publishZone,
long maxFileBytes,
Path workDir,
String serverLogin,
String arweaveGateway,
Path arweaveWalletJwkPath,
int arweaveMinConfirmations,
int arweaveConfirmPollSeconds,
int arweaveConfirmTimeoutMinutes,
Path solanaRootKeyPath,
Path solanaClientKeyPath,
String solanaRpcUrl,
String solanaUsersProgramId,
String solanaPaymentsProgramId,
int solanaConfirmPollSeconds,
int solanaConfirmTimeoutMinutes,
String solanaCommitment
) {
public static ArchivePublisherConfig load() {
AppConfig c = AppConfig.getInstance();
return new ArchivePublisherConfig(
c.getBoolean("archive.publish.enabled", false),
parseTime(orDefault(c.getParam("archive.publish.time"), "00:00")),
parseZone(c.getParam("archive.publish.zoneId")),
positiveLong(parseLong(c.getParam("archive.maxFileBytes"), 4_000_000_000L), "archive.maxFileBytes"),
Path.of(orDefault(c.getParam("archive.workDir"), "data/archive")),
requiredIfEnabled(c, "server.SHiNE.login"),
orDefault(c.getParam("archive.arweave.gateway"), "https://arweave.net"),
optionalPath(c.getParam("archive.arweave.walletJwkPath")),
nonNegative(c.getInt("archive.arweave.minConfirmations", 1), "archive.arweave.minConfirmations"),
positive(c.getInt("archive.arweave.confirmPollSeconds", 30), "archive.arweave.confirmPollSeconds"),
positive(c.getInt("archive.arweave.confirmTimeoutMinutes", 180), "archive.arweave.confirmTimeoutMinutes"),
optionalPath(c.getParam("archive.solana.rootKeyPath")),
optionalPath(c.getParam("archive.solana.clientKeyPath")),
firstNonBlank(c.getParam("solana.users.sync.rpcUrl"), c.getParam("solana.rpcUrl"), SolanaProgramsConfig.SOLANA_RPC_URL),
firstNonBlank(c.getParam("solana.users.sync.programId"), SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID),
SolanaProgramsConfig.SHINE_PAYMENTS_PROGRAM_ID,
positive(c.getInt("archive.solana.confirmPollSeconds", 5), "archive.solana.confirmPollSeconds"),
positive(c.getInt("archive.solana.confirmTimeoutMinutes", 30), "archive.solana.confirmTimeoutMinutes"),
orDefault(c.getParam("archive.solana.commitment"), "finalized")
);
}
public void validateForStart() {
if (!enabled) return;
if (serverLogin == null || serverLogin.isBlank()) throw new IllegalArgumentException("server.SHiNE.login обязателен для archive publisher");
if (arweaveWalletJwkPath == null) throw new IllegalArgumentException("archive.arweave.walletJwkPath обязателен");
if (solanaRootKeyPath == null) throw new IllegalArgumentException("archive.solana.rootKeyPath обязателен");
if (solanaClientKeyPath == null) throw new IllegalArgumentException("archive.solana.clientKeyPath обязателен");
if (solanaRpcUrl == null || solanaRpcUrl.isBlank()) throw new IllegalArgumentException("solana.users.sync.rpcUrl или solana.rpcUrl обязателен");
if (maxFileBytes >= 0x1_0000_0000L) {
throw new IllegalArgumentException("SHINE-ARCHIVE v1 требует archive.maxFileBytes < 4294967296");
}
}
private static String requiredIfEnabled(AppConfig c, String name) {
String v = c.getParam(name);
return v == null ? "" : v.trim();
}
private static Path optionalPath(String s) { return s == null || s.isBlank() ? null : Path.of(s.trim()); }
private static String firstNonBlank(String... values) {
if (values != null) for (String v : values) if (v != null && !v.isBlank()) return v.trim();
return "";
}
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
private static long parseLong(String s, long d) { return s == null || s.isBlank() ? d : Long.parseLong(s.trim()); }
private static LocalTime parseTime(String value) {
try {
return LocalTime.parse(value.trim());
} catch (Exception e) {
throw new IllegalArgumentException("archive.publish.time должен быть в формате HH:mm, например 00:00", e);
}
}
private static ZoneId parseZone(String value) {
if (value == null || value.isBlank()) return ZoneId.systemDefault();
try {
return ZoneId.of(value.trim());
} catch (Exception e) {
throw new IllegalArgumentException("Некорректный archive.publish.zoneId: " + value, e);
}
}
private static int positive(int v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
private static int nonNegative(int v, String n) { if (v < 0) throw new IllegalArgumentException(n + " должен быть >= 0"); return v; }
private static long positiveLong(long v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
}
@@ -1,90 +0,0 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Опциональный scheduler: один новый archive job в заданное локальное время каждый день. */
public final class ArchivePublisherScheduler {
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherScheduler.class);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static ScheduledExecutorService executor;
private ArchivePublisherScheduler() {}
public static void startOrLog() {
ArchivePublisherConfig cfg;
try {
cfg = ArchivePublisherConfig.load();
} catch (Exception e) {
log.error("Не удалось прочитать archive publisher config", e);
return;
}
if (!cfg.enabled()) {
log.info("Archive publisher выключен (archive.publish.enabled=false)");
return;
}
if (!STARTED.compareAndSet(false, true)) return;
try {
ArchivePublisherService service = new ArchivePublisherService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override public Thread newThread(Runnable r) {
Thread t = new Thread(r, "shine-archive-publisher");
t.setDaemon(true);
return t;
}
});
// Незавершённую публикацию после рестарта продолжаем сразу, а новый snapshot
// создаём только в очередное назначенное суточное время.
executor.execute(() -> {
try {
service.resumeUnfinishedJob();
} catch (Exception e) {
log.error("Не удалось продолжить незавершённый archive job", e);
}
});
scheduleNextDaily(service, cfg);
log.info("Archive publisher включён: login={} dir={} dailyAt={} zone={}",
cfg.serverLogin(), cfg.workDir(), cfg.publishTime(), cfg.publishZone());
} catch (Exception e) {
STARTED.set(false);
log.error("Archive publisher НЕ запущен из-за ошибки preflight", e);
}
}
private static void scheduleNextDaily(ArchivePublisherService service, ArchivePublisherConfig cfg) {
ScheduledExecutorService e = executor;
if (e == null || e.isShutdown()) return;
ZonedDateTime now = ZonedDateTime.now(cfg.publishZone());
ZonedDateTime next = now.toLocalDate().atTime(cfg.publishTime()).atZone(cfg.publishZone());
if (!next.isAfter(now)) next = next.plusDays(1);
long delayMs = Math.max(1L, Duration.between(now, next).toMillis());
e.schedule(() -> {
try {
service.runCycle();
} catch (Exception ex) {
log.error("Archive publisher daily cycle failed; retry нового snapshot будет в следующее назначенное время", ex);
} finally {
scheduleNextDaily(service, cfg);
}
}, delayMs, TimeUnit.MILLISECONDS);
log.info("Следующая архивная публикация запланирована на {}", next);
}
public static void close() {
ScheduledExecutorService e = executor;
if (e != null) e.shutdownNow();
}
}
@@ -1,341 +0,0 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchiveChainCursor;
import shine.db.archive.ArchivePublishJob;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.dao.ArchivePublicationDAO;
import shine.db.dao.ArchiveImportDAO;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.BlockchainStateEntry;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
/** Один crash-safe цикл публикации большого SHINE-ARCHIVE блока. */
public final class ArchivePublisherService {
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherService.class);
private final ArchivePublisherConfig cfg;
private final ArchivePublicationDAO archiveDao = ArchivePublicationDAO.getInstance();
private final ArchiveImportDAO archiveImportDao = ArchiveImportDAO.getInstance();
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
private final ShineArchiveWriter writer = new ShineArchiveWriter();
private final ArweaveArchiveService arweave;
private final SolanaArchiveHeadWriter solana;
private final byte[] archiveSigningSeed32;
private final AtomicBoolean running = new AtomicBoolean(false);
public ArchivePublisherService(ArchivePublisherConfig cfg) throws Exception {
this.cfg = Objects.requireNonNull(cfg);
cfg.validateForStart();
Files.createDirectories(cfg.workDir());
this.archiveSigningSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
this.arweave = new ArweaveArchiveService(cfg);
this.solana = new SolanaArchiveHeadWriter(cfg);
this.solana.validateKeysAgainstPda();
backfillOwnLocationIndex();
}
/**
* После обновления старого publisher-сервера заполняет универсальный location index
* из уже существующих finalized cursors, не дожидаясь следующего суточного archive block.
*/
private void backfillOwnLocationIndex() throws Exception {
Map<Long, ArchiveBigBlockRef> finalized = new HashMap<>();
for (ArchiveBigBlockRef ref : archiveDao.listFinalizedBigBlocks()) finalized.put(ref.bigBlockNumber(), ref);
if (finalized.isEmpty()) return;
long now = System.currentTimeMillis();
int updated = 0;
for (BlockchainStateEntry state : stateDao.listAll()) {
if (state == null || state.getBlockchainName() == null || state.getBlockchainName().isBlank()) continue;
ArchiveChainCursor cursor = archiveDao.getCursor(state.getBlockchainName());
if (cursor == null) continue;
ArchiveBigBlockRef ref = finalized.get(cursor.lastArchiveBigBlockNumber());
if (ref == null || ref.arweaveTxId() == null || ref.arweaveTxId().length != 32) continue;
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
cursor.blockchainName(), cfg.serverLogin(), b64url(ref.arweaveTxId()),
cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(),
cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), now));
updated++;
}
if (updated > 0) log.info("Archive publisher: восстановлен location index для {} blockchain", updated);
}
/** После рестарта продолжает только уже существующий незавершённый job; новый snapshot не создаёт. */
public boolean resumeUnfinishedJob() throws Exception {
if (!running.compareAndSet(false, true)) return false;
try {
ArchivePublishJob job = archiveDao.findUnfinishedJob();
if (job == null) return false;
log.info("Archive publisher: после рестарта продолжаем job id={} block={} status={}",
job.id(), job.bigBlockNumber(), job.status());
process(job.id());
return true;
} finally {
running.set(false);
}
}
/** Запускает суточный цикл: продолжает незавершённый job либо создаёт новый frozen snapshot. */
public boolean runCycle() throws Exception {
if (!running.compareAndSet(false, true)) {
log.info("Archive publisher: предыдущий цикл ещё работает, новый пропущен");
return false;
}
try {
ArchivePublishJob job = archiveDao.findUnfinishedJob();
if (job == null) {
job = createFrozenJob();
if (job == null) {
log.info("Archive publisher: новых SHiNE-блоков нет");
return false;
}
} else {
log.info("Archive publisher: продолжаем незавершённый job id={} block={} status={}",
job.id(), job.bigBlockNumber(), job.status());
}
process(job.id());
return true;
} finally {
running.set(false);
}
}
private ArchivePublishJob createFrozenJob() throws Exception {
List<ArchivePublishJobChain> chains = new ArrayList<>();
for (BlockchainStateEntry listed : stateDao.listAll()) {
if (listed == null || listed.getBlockchainName() == null || listed.getBlockchainName().isBlank()) continue;
String name = listed.getBlockchainName();
ReentrantLock lock = BlockchainLocks.lockFor(name);
lock.lock();
try {
BlockchainStateEntry state = stateDao.getByBlockchainName(name);
if (state == null || state.getLastBlockNumber() < 0) continue;
ArchiveChainCursor cursor = archiveDao.getCursor(name);
long from;
byte[] previousSourceHash;
Long prevBig = null;
byte[] prevBigHash = null;
Long prevChunkOffset = null;
Long prevChunkSize = null;
if (cursor == null) {
from = 0L;
previousSourceHash = new byte[32];
} else {
long cursorBlock = cursor.lastArchivedSourceBlockNumber();
if (cursorBlock < 0 || cursorBlock > 0xffff_ffffL) {
throw new IllegalStateException("Некорректный archive cursor для " + name + ": " + cursorBlock);
}
byte[] actualCursorHash = blocksDao.getHashByNumber(name, (int) cursorBlock);
if (!Arrays.equals(actualCursorHash, cursor.lastArchivedSourceBlockHash())) {
throw new IllegalStateException("Archive cursor hash не совпадает с локальной blockchain: " + name + "#" + cursorBlock);
}
from = cursorBlock + 1L;
previousSourceHash = cursor.lastArchivedSourceBlockHash();
prevBig = cursor.lastArchiveBigBlockNumber();
prevBigHash = cursor.lastArchiveBigBlockHash();
prevChunkOffset = cursor.lastChunkOffset();
prevChunkSize = cursor.lastChunkSize();
}
long to = Integer.toUnsignedLong(state.getLastBlockNumber());
if (from > to) continue;
if (from > 0xffff_ffffL || to > 0xffff_ffffL) throw new IllegalStateException("Source block number вне u32: " + name);
byte[] lastHash = state.getLastBlockHash();
if (lastHash == null || lastHash.length != 32) {
lastHash = blocksDao.getHashByNumber(name, (int) to);
}
require32(lastHash, "last source block hash " + name);
chains.add(new ArchivePublishJobChain(
0L, name, from, to, previousSourceHash, lastHash,
prevBig, prevBigHash, prevChunkOffset, prevChunkSize, null, null));
} finally {
lock.unlock();
}
}
if (chains.isEmpty()) return null;
chains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
long bigBlock = archiveDao.nextBigBlockNumber();
if (bigBlock > 0xffff_ffffL) throw new IllegalStateException("Закончился диапазон u32 big_block_number");
long createdAt = System.currentTimeMillis();
long id = archiveDao.createJobWithChains(bigBlock, createdAt, chains);
log.info("Archive snapshot создан: job={} bigBlock={} chains={}", id, bigBlock, chains.size());
return archiveDao.getJob(id);
}
private void process(long jobId) throws Exception {
while (true) {
ArchivePublishJob job = archiveDao.getJob(jobId);
if (job == null) throw new IllegalStateException("Archive job исчез: " + jobId);
switch (job.status()) {
case "SNAPSHOT_CREATED" -> buildFile(job);
case "FILE_BUILT" -> uploadArweave(job);
case "ARWEAVE_UPLOADED" -> waitArweave(job);
case "ARWEAVE_CONFIRMED" -> submitSolana(job);
case "SOLANA_SUBMITTED" -> waitSolana(job);
case "SOLANA_FINALIZED" -> commitCursors(job);
case "CURSORS_COMMITTED" -> {
log.info("Archive job завершён: id={} bigBlock={} file={} tx={}", job.id(), job.bigBlockNumber(), job.localArchivePath(), b64url(job.arweaveTxId()));
return;
}
case "FAILED" -> throw new IllegalStateException("Archive job помечен FAILED: " + job.errorText());
default -> throw new IllegalStateException("Неизвестный archive job status: " + job.status());
}
}
}
private void buildFile(ArchivePublishJob job) throws Exception {
List<ArchivePublishJobChain> chains = archiveDao.listJobChains(job.id());
if (chains.isEmpty()) throw new IllegalStateException("SNAPSHOT_CREATED job не содержит chains: " + job.id());
Map<String,List<BlockEntry>> records = new LinkedHashMap<>();
for (ArchivePublishJobChain ch : chains) {
ReentrantLock lock = BlockchainLocks.lockFor(ch.blockchainName());
lock.lock();
try {
if (ch.fromSourceBlockNumber() > 0) {
byte[] prev = blocksDao.getHashByNumber(ch.blockchainName(), (int)(ch.fromSourceBlockNumber() - 1));
if (!Arrays.equals(prev, ch.previousSourceBlockHash())) {
throw new IllegalStateException("Frozen previous source hash изменился: " + ch.blockchainName());
}
}
List<BlockEntry> range = blocksDao.listRangeByNumber(
ch.blockchainName(), (int)ch.fromSourceBlockNumber(), (int)ch.toSourceBlockNumber());
if (range.isEmpty()) throw new IllegalStateException("Frozen range пуст: " + ch.blockchainName());
byte[] last = range.get(range.size()-1).getBlockHash();
if (!Arrays.equals(last, ch.lastSourceBlockHash())) {
throw new IllegalStateException("Frozen last source hash изменился: " + ch.blockchainName());
}
records.put(ch.blockchainName(), range);
} finally {
lock.unlock();
}
}
List<ArchiveBigBlockRef> previous = archiveDao.listFinalizedBigBlocks();
Path file = ArchiveFileNames.pendingFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone());
ShineArchiveWriter.BuildResult result = writer.build(
file, cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), previous,
chains, records, archiveSigningSeed32, cfg.maxFileBytes());
for (Map.Entry<String, ShineArchiveWriter.ChunkPosition> e : result.chunks().entrySet()) {
archiveDao.updateNewChunkPosition(job.id(), e.getKey(), e.getValue().offset(), e.getValue().size());
}
archiveDao.markFileBuilt(job.id(), result.file().toString(), result.archiveHash());
log.info("SHINE-ARCHIVE локально сохранён: block={} file={} bytes={} hash={}",
job.bigBlockNumber(), result.file(), result.fileSize(), hex(result.archiveHash()));
}
private void uploadArweave(ArchivePublishJob job) throws Exception {
Path file = requireLocalFile(job);
byte[] verified = ShineArchiveWriter.verifyLocalFile(file);
if (!Arrays.equals(verified, job.archiveHash())) throw new IllegalStateException("Локальный archive hash отличается от job DB");
ArweaveArchiveService.UploadResult upload = arweave.upload(file, cfg.serverLogin(), job.bigBlockNumber(), job.archiveHash());
// Сначала надёжно фиксируем полученный TX ID в БД. Если процесс упадёт до rename,
// recovery на следующем шаге переименует тот же временный файл без повторной загрузки.
archiveDao.markArweaveUploaded(job.id(), upload.txIdBytes());
Path uploadedFile = markLocalFileUploaded(job, file, upload.txIdBytes());
log.info("SHINE-ARCHIVE загружен в Arweave: block={} tx={} file={}",
job.bigBlockNumber(), upload.txId(), uploadedFile);
}
private void waitArweave(ArchivePublishJob job) throws Exception {
ensureUploadedLocalName(job);
String tx = b64url(job.arweaveTxId());
int confirmations = arweave.waitForConfirmations(tx);
archiveDao.markArweaveConfirmed(job.id(), confirmations);
log.info("Arweave archive закреплён: block={} tx={} confirmations={}", job.bigBlockNumber(), tx, confirmations);
}
/** После успешного Arweave upload временный файл получает реальный TX ID в имени и остаётся локально. */
private Path markLocalFileUploaded(ArchivePublishJob job, Path currentFile, byte[] txId) throws Exception {
require32(txId, "arweave tx id");
Path target = ArchiveFileNames.uploadedFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), txId);
Files.createDirectories(target.toAbsolutePath().getParent());
if (!currentFile.equals(target)) {
try {
Files.move(currentFile, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(currentFile, target, StandardCopyOption.REPLACE_EXISTING);
}
}
archiveDao.updateLocalArchivePath(job.id(), target.toString());
return target;
}
/** Crash recovery: TX уже сохранён, а временный файл ещё не успели переименовать. */
private void ensureUploadedLocalName(ArchivePublishJob job) throws Exception {
require32(job.arweaveTxId(), "job.arweave_tx_id");
Path target = ArchiveFileNames.uploadedFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), job.arweaveTxId());
if (Files.isRegularFile(target)) {
if (!target.toString().equals(job.localArchivePath())) {
archiveDao.updateLocalArchivePath(job.id(), target.toString());
}
return;
}
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) return;
Path current = Path.of(job.localArchivePath());
if (Files.isRegularFile(current)) {
markLocalFileUploaded(job, current, job.arweaveTxId());
}
}
private void submitSolana(ArchivePublishJob job) throws Exception {
require32(job.arweaveTxId(), "job.arweave_tx_id");
require32(job.archiveHash(), "job.archive_hash");
SolanaArchiveHeadWriter.SubmitResult submitted = solana.submitArchiveHead(job.arweaveTxId(), job.archiveHash());
archiveDao.markSolanaSubmitted(job.id(), submitted.signature());
}
private void waitSolana(ArchivePublishJob job) throws Exception {
if (job.solanaSignature() == null || job.solanaSignature().isBlank()) throw new IllegalStateException("SOLANA_SUBMITTED без signature");
solana.waitFinalized(job.solanaSignature());
if (!solana.currentHeadMatches(job.arweaveTxId(), job.archiveHash())) {
throw new IllegalStateException("После Solana finalized User PDA archive head не совпадает с job");
}
archiveDao.markSolanaFinalized(job.id());
log.info("Solana archive head finalized: block={} signature={}", job.bigBlockNumber(), job.solanaSignature());
}
private void commitCursors(ArchivePublishJob job) throws Exception {
archiveDao.commitCursors(job.id(), job.archiveHash());
// Тот же универсальный индекс используется и для собственных опубликованных архивов.
// Поэтому UI publisher-сервера получает ссылку даже если он не импортирует самого себя через whitelist.
String txId = b64url(job.arweaveTxId());
long now = System.currentTimeMillis();
for (ArchivePublishJobChain ch : archiveDao.listJobChains(job.id())) {
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) continue;
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
ch.blockchainName(), cfg.serverLogin(), txId, job.archiveHash(), job.bigBlockNumber(),
ch.newChunkOffset(), ch.newChunkSize(), ch.toSourceBlockNumber(), now));
}
}
private static Path requireLocalFile(ArchivePublishJob job) {
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) throw new IllegalStateException("FILE_BUILT без local_archive_path");
Path p = Path.of(job.localArchivePath());
if (!Files.isRegularFile(p)) throw new IllegalStateException("Локальный archive-файл отсутствует: " + p);
return p;
}
private static void require32(byte[] v, String name) {
if (v == null || v.length != 32) throw new IllegalStateException(name + " должен быть 32 bytes");
}
private static String b64url(byte[] v) { return v == null ? "" : Base64.getUrlEncoder().withoutPadding().encodeToString(v); }
private static String hex(byte[] b){StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
}
@@ -1,259 +0,0 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.*;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PSSParameterSpec;
import java.security.spec.RSAPrivateCrtKeySpec;
import java.time.Duration;
import java.util.*;
/**
* Публикация готовых локальных SHINE-ARCHIVE файлов в Arweave v2.
* Поддерживает стандартный data Merkle tree и загрузку через /chunk.
*/
public final class ArweaveArchiveService {
private static final Logger log = LoggerFactory.getLogger(ArweaveArchiveService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder B64D = Base64.getUrlDecoder();
private final ArchivePublisherConfig cfg;
private final HttpClient http;
private volatile Wallet wallet;
public ArweaveArchiveService(ArchivePublisherConfig cfg) {
this.cfg = Objects.requireNonNull(cfg);
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
}
public record UploadResult(String txId, byte[] txIdBytes, String gateway) {}
public UploadResult upload(Path archiveFile, String publisherLogin, long bigBlockNumber, byte[] archiveHash)
throws Exception {
long dataSize = Files.size(archiveFile);
if (dataSize <= 0) throw new IllegalArgumentException("Пустой SHINE-ARCHIVE");
Wallet w = wallet();
String gateway = trimGateway(cfg.arweaveGateway());
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(archiveFile);
String anchor = getRequiredText(gateway, "/tx_anchor");
String reward = getRequiredText(gateway, "/price/" + dataSize);
if (!reward.matches("^\\d+$")) throw new IOException("Arweave вернул некорректную цену: " + reward);
verifyBalance(gateway, w.address(), reward);
List<List<byte[]>> tagList = new ArrayList<>();
List<Map<String,String>> jsonTags = new ArrayList<>();
addTag(jsonTags, tagList, "Content-Type", "application/octet-stream");
addTag(jsonTags, tagList, "App-Name", "SHiNE");
addTag(jsonTags, tagList, "SHiNE-Type", "archive");
addTag(jsonTags, tagList, "SHiNE-Archive-Version", "1.0");
addTag(jsonTags, tagList, "SHiNE-Publisher", publisherLogin);
addTag(jsonTags, tagList, "SHiNE-Big-Block", Long.toUnsignedString(bigBlockNumber));
addTag(jsonTags, tagList, "SHiNE-Archive-SHA256", hex(archiveHash));
byte[] signaturePayload = deepHash(List.of(
utf8("2"),
b64Decode(w.owner()),
new byte[0],
utf8("0"),
utf8(reward),
b64Decode(anchor),
tagList,
utf8(Long.toString(dataSize)),
merkle.dataRoot()
));
byte[] rawSignature = signPayload(w.privateKey(), signaturePayload);
byte[] txIdBytes = sha256(rawSignature);
String txId = b64(txIdBytes);
Map<String,Object> tx = new LinkedHashMap<>();
tx.put("format", 2);
tx.put("id", txId);
tx.put("last_tx", anchor);
tx.put("owner", w.owner());
tx.put("tags", jsonTags);
tx.put("target", "");
tx.put("quantity", "0");
tx.put("data_root", b64(merkle.dataRoot()));
tx.put("data_size", Long.toString(dataSize));
tx.put("data", "");
tx.put("reward", reward);
tx.put("signature", b64(rawSignature));
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
log.info("Arweave tx header принят: tx={}, chunks={}, bytes={}", txId, merkle.chunks().size(), dataSize);
for (int i=0; i<merkle.chunks().size(); i++) {
ArweaveMerkle.Chunk chunkMeta = merkle.chunks().get(i);
ArweaveMerkle.Proof proof = merkle.proofs().get(i);
byte[] chunk = ArweaveMerkle.readChunk(archiveFile, chunkMeta);
Map<String,Object> body = new LinkedHashMap<>();
body.put("data_root", b64(merkle.dataRoot()));
body.put("data_size", Long.toString(dataSize));
body.put("data_path", b64(proof.proof()));
body.put("offset", Long.toString(proof.offset()));
body.put("chunk", b64(chunk));
postChunkWithRetry(gateway, body, i, merkle.chunks().size());
}
return new UploadResult(txId, txIdBytes, gateway);
}
/** Ждёт включения tx в блок и заданного количества подтверждений. */
public int waitForConfirmations(String txId) throws Exception {
if (cfg.arweaveMinConfirmations() <= 0) return 0;
String gateway = trimGateway(cfg.arweaveGateway());
long deadline = System.currentTimeMillis() + cfg.arweaveConfirmTimeoutMinutes() * 60_000L;
int last = 0;
while (System.currentTimeMillis() < deadline) {
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/tx/" + txId + "/status"))
.timeout(Duration.ofSeconds(20)).GET().build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() == 200) {
JsonNode json = MAPPER.readTree(resp.body());
last = json.path("number_of_confirmations").asInt(0);
if (last >= cfg.arweaveMinConfirmations()) return last;
} else if (resp.statusCode() != 202 && resp.statusCode() != 404) {
log.warn("Arweave status tx={} HTTP {}: {}", txId, resp.statusCode(), safe(resp.body()));
}
Thread.sleep(cfg.arweaveConfirmPollSeconds() * 1000L);
}
throw new IOException("Истёк timeout ожидания Arweave confirmations для " + txId + ", last=" + last);
}
private void postChunkWithRetry(String gateway, Map<String,Object> body, int index, int total) throws Exception {
Exception last = null;
for (int attempt=1; attempt<=12; attempt++) {
try {
postJsonExpect2xx(gateway + "/chunk", body, Duration.ofSeconds(60));
if ((index + 1) % 25 == 0 || index + 1 == total) {
log.info("Arweave chunks: {}/{}", index + 1, total);
}
return;
} catch (Exception e) {
last = e;
if (attempt == 12) break;
log.warn("Arweave chunk {}/{} attempt {}/12: {}", index + 1, total, attempt, e.getMessage());
Thread.sleep(5_000L);
}
}
throw new IOException("Не удалось загрузить Arweave chunk " + (index + 1) + "/" + total, last);
}
private void postJsonExpect2xx(String url, Object body, Duration timeout) throws Exception {
String json = MAPPER.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.timeout(timeout)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("HTTP " + resp.statusCode() + " " + safe(resp.body()));
}
}
private void verifyBalance(String gateway, String address, String reward) throws Exception {
if (address == null || address.isBlank()) return;
String balance = getRequiredText(gateway, "/wallet/" + address + "/balance");
if (balance.matches("^\\d+$") && new BigInteger(balance).compareTo(new BigInteger(reward)) < 0) {
throw new IllegalStateException("Недостаточно AR: balance=" + balance + ", reward=" + reward);
}
}
private String getRequiredText(String gateway, String path) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + path)).timeout(Duration.ofSeconds(20)).GET().build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("Arweave HTTP " + resp.statusCode() + " для " + path + ": " + safe(resp.body()));
}
String s = resp.body() == null ? "" : resp.body().trim();
if (s.isBlank()) throw new IOException("Пустой Arweave ответ для " + path);
return s;
}
private Wallet wallet() throws Exception {
Wallet local = wallet;
if (local != null) return local;
synchronized (this) {
if (wallet != null) return wallet;
JsonNode jwk = MAPPER.readTree(Files.readString(cfg.arweaveWalletJwkPath(), StandardCharsets.UTF_8));
String owner = required(jwk, "n");
PrivateKey pk = buildPrivateKey(jwk);
String address = b64(sha256(b64Decode(owner)));
wallet = new Wallet(owner, address, pk);
return wallet;
}
}
private static PrivateKey buildPrivateKey(JsonNode jwk) throws GeneralSecurityException {
RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec(
big(required(jwk,"n")), big(required(jwk,"e")), big(required(jwk,"d")),
big(required(jwk,"p")), big(required(jwk,"q")), big(required(jwk,"dp")),
big(required(jwk,"dq")), big(required(jwk,"qi")));
return KeyFactory.getInstance("RSA").generatePrivate(spec);
}
private static byte[] signPayload(PrivateKey key, byte[] payload) throws GeneralSecurityException {
Signature signature = Signature.getInstance("RSASSA-PSS");
signature.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
signature.initSign(key);
signature.update(payload);
return signature.sign();
}
private static byte[] deepHash(Object data) throws GeneralSecurityException {
if (data instanceof List<?> list) {
byte[] tag = concat(utf8("list"), utf8(Integer.toString(list.size())));
return deepHashChunks(list, sha384(tag));
}
if (!(data instanceof byte[] bytes)) throw new IllegalArgumentException("deepHash: ожидается byte[]/List");
byte[] tag = concat(utf8("blob"), utf8(Integer.toString(bytes.length)));
return sha384(concat(sha384(tag), sha384(bytes)));
}
private static byte[] deepHashChunks(List<?> list, byte[] acc) throws GeneralSecurityException {
byte[] cur = acc;
for (Object item : list) cur = sha384(concat(cur, deepHash(item)));
return cur;
}
private static void addTag(List<Map<String,String>> json, List<List<byte[]>> raw, String name, String value) {
byte[] n=utf8(name), v=utf8(value);
Map<String,String> item = new LinkedHashMap<>();
item.put("name", b64(n)); item.put("value", b64(v));
json.add(item); raw.add(List.of(n,v));
}
private static String required(JsonNode node, String field) {
String s = node == null ? "" : node.path(field).asText("").trim();
if (s.isBlank()) throw new IllegalStateException("В Arweave JWK отсутствует " + field);
return s;
}
private static BigInteger big(String s) { return new BigInteger(1, b64Decode(s)); }
private static String trimGateway(String s) { return (s == null ? "https://arweave.net" : s.trim()).replaceAll("/+$", ""); }
private static byte[] utf8(String s) { return String.valueOf(s == null ? "" : s).getBytes(StandardCharsets.UTF_8); }
private static byte[] b64Decode(String s) { return B64D.decode(s); }
private static String b64(byte[] b) { return B64.encodeToString(b); }
private static byte[] sha256(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-256").digest(b); }
private static byte[] sha384(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-384").digest(b); }
private static String hex(byte[] b) { StringBuilder s=new StringBuilder(); for(byte x:b)s.append(String.format("%02x",x)); return s.toString(); }
private static byte[] concat(byte[]... arrays) { int n=0; for(byte[] a:arrays)n=Math.addExact(n,a.length); byte[] o=new byte[n]; int p=0; for(byte[]a:arrays){System.arraycopy(a,0,o,p,a.length);p+=a.length;} return o; }
private static String safe(String s) { s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim(); return s.length()<=250?s:s.substring(0,250)+"..."; }
private record Wallet(String owner, String address, PrivateKey privateKey) {}
}
@@ -0,0 +1,37 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Fixed-delay publisher. Empty cycles are no-ops. */
public final class ArweaveBlockPublisherScheduler {
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherScheduler.class);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static ScheduledExecutorService executor;
private ArweaveBlockPublisherScheduler() {}
public static void startOrLog() {
ArweaveBlocksConfig cfg;
try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); }
catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; }
if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled"); return; }
if (!STARTED.compareAndSet(false,true)) return;
try {
ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "shine-arweave-block-publisher"); t.setDaemon(true); return t;
});
Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } };
executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES);
log.info("Arweave user-block publisher enabled: interval={}m gateway={}", cfg.publishIntervalMinutes(), cfg.publishGateway());
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); }
}
public static void close() { ScheduledExecutorService e=executor; if(e!=null)e.shutdownNow(); }
}
@@ -0,0 +1,59 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.dao.BlocksDAO;
import shine.db.entities.BlockEntry;
import java.util.ArrayList;
import java.util.List;
/** Publishes locally-created signed user DataItems as one standard ANS-104 bundle. */
public final class ArweaveBlockPublisherService {
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class);
private final ArweaveBlocksConfig cfg;
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
private final ArweaveL1Uploader uploader;
public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) {
this.cfg = cfg;
this.uploader = new ArweaveL1Uploader(cfg);
}
public int runCycle() throws Exception {
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
if (candidates.isEmpty()) return 0;
List<byte[]> items = new ArrayList<>();
List<byte[]> ids = new ArrayList<>();
long estimated = 32;
for (BlockEntry e : candidates) {
byte[] raw = e.getBlockBytes();
byte[] id = e.getDataItemId();
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
long next = estimated + 64L + raw.length;
if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break;
if (next > cfg.publishMaxBundleBytes()) {
log.error("One DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}",
e.getBchName(), e.getBlockNumber(), raw.length);
continue;
}
items.add(raw); ids.add(id); estimated = next;
}
if (items.isEmpty()) return 0;
byte[] bundle = Ans104Bundle.write(items);
List<ArweaveL1Uploader.Tag> rootTags = List.of(
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0"),
// Deliberately different from App=test5590 so discovery returns child DataItems only.
new ArweaveL1Uploader.Tag("App", "test5590-batch")
);
ArweaveL1Uploader.UploadResult result = uploader.upload(bundle, rootTags);
blocksDAO.markArweavePublished(ids, result.txId(), System.currentTimeMillis());
log.info("Published {} SHiNE test DataItems in root tx {} (bundle={} bytes)", items.size(), result.txId(), bundle.length);
return items.size();
}
}
@@ -0,0 +1,32 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public final class ArweaveBlockSyncScheduler {
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncScheduler.class);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static ScheduledExecutorService executor;
private ArweaveBlockSyncScheduler() {}
public static void startOrLog() {
ArweaveBlocksConfig cfg;
try { cfg = ArweaveBlocksConfig.load(); }
catch (Exception e) { log.error("Cannot read Arweave block sync config", e); return; }
if (!cfg.syncEnabled()) { log.info("Arweave user-block sync disabled"); return; }
if (!STARTED.compareAndSet(false,true)) return;
try {
ArweaveBlockSyncService service = new ArweaveBlockSyncService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t=new Thread(r,"shine-arweave-block-sync");t.setDaemon(true);return t; });
Runnable task=()->{try{service.runCycle();}catch(Exception e){log.error("Arweave block sync cycle failed",e);}};
executor.scheduleWithFixedDelay(task,0,cfg.syncIntervalMinutes(),TimeUnit.MINUTES);
log.info("Arweave user-block sync enabled: interval={}m gateway={}",cfg.syncIntervalMinutes(),cfg.syncGateway());
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block sync failed to start",e); }
}
public static void close(){ScheduledExecutorService e=executor;if(e!=null)e.shutdownNow();}
}
@@ -0,0 +1,185 @@
package server.archive;
import blockchain.Ans104DataItem;
import blockchain.BchBlockEntry;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import shine.db.dao.ArweaveBlockImportDAO;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.dao.SolanaUserPdaCurrentDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.SolanaUserPdaCurrentEntry;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
/**
* Discovers App=test5590 child DataItems, extracts their exact serialized bytes
* from the root ANS-104 bundle and imports them through the normal AddBlock checks.
*/
public final class ArweaveBlockSyncService {
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
private final ArweaveBlocksConfig cfg;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance();
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance();
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler();
public ArweaveBlockSyncService(ArweaveBlocksConfig cfg) { this.cfg = cfg; }
public void runCycle() throws Exception {
discover();
drainQueue();
}
private void discover() throws Exception {
long stored = importDAO.getLastBlockHeight();
long minHeight = Math.max(cfg.syncStartBlockHeight(), stored);
String cursor = null;
long maxHeight = minHeight;
Map<String, byte[]> rootCache = new HashMap<>();
int discovered = 0;
do {
JsonNode response = graphQlPage(minHeight, cursor);
JsonNode txs = response.path("data").path("transactions");
if (response.has("errors")) throw new IOException("Arweave GraphQL errors: " + response.path("errors"));
JsonNode edges = txs.path("edges");
if (!edges.isArray()) throw new IOException("Arweave GraphQL returned no transactions.edges");
String nextCursor = null;
for (JsonNode edge : edges) {
nextCursor = edge.path("cursor").asText(null);
JsonNode node = edge.path("node");
String itemIdText = node.path("id").asText("").trim();
String rootTx = node.path("bundledIn").path("id").asText("").trim();
long height = node.path("block").path("height").asLong(-1L);
if (itemIdText.isBlank() || rootTx.isBlank() || height < 0) continue;
maxHeight = Math.max(maxHeight, height);
byte[] id32;
try { id32 = B64URL.decode(itemIdText); }
catch (IllegalArgumentException bad) { continue; }
if (id32.length != 32 || blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
byte[] bundle = rootCache.computeIfAbsent(rootTx, key -> {
try { return downloadRootBundle(key); }
catch (Exception e) { throw new RootDownloadRuntimeException(e); }
});
byte[] raw = Ans104Bundle.find(bundle, id32, Math.max(cfg.publishMaxItems() * 4, 100_000));
if (raw == null) throw new IOException("DataItem " + itemIdText + " not found in root bundle " + rootTx);
Ans104DataItem parsed = new Ans104DataItem(raw);
if (!parsed.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) continue;
if (!parsed.verifySignature()) throw new IOException("Bad ANS-104 signature for " + itemIdText);
importDAO.enqueueIfMissing(id32, rootTx, height, raw, System.currentTimeMillis());
discovered++;
}
boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false);
cursor = hasNext ? nextCursor : null;
if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor");
} while (cursor != null);
// Keep one-height overlap: the next query includes this height and deduplicates IDs.
importDAO.setLastBlockHeight(maxHeight, System.currentTimeMillis());
if (discovered > 0) log.info("Arweave discovery queued {} new SHiNE test DataItems through height {}", discovered, maxHeight);
}
private void drainQueue() throws Exception {
int passes = 0;
int importedTotal = 0;
boolean progress;
do {
progress = false;
List<ArweaveBlockImportDAO.QueueItem> pending = importDAO.listPending(cfg.syncQueueBatchSize());
if (pending.isEmpty()) break;
for (ArweaveBlockImportDAO.QueueItem q : pending) {
Ans104DataItem item;
BchBlockEntry block;
try {
item = new Ans104DataItem(q.rawDataItem());
if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch");
if (!item.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) throw new IllegalArgumentException("bad App tag");
if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature");
block = new BchBlockEntry(q.rawDataItem());
} catch (Exception e) {
importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis());
continue;
}
SolanaUserPdaCurrentEntry user = usersDAO.getByBlockchainKey(item.owner32());
if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) {
importDAO.setPendingError(q.dataItemId(), "unknown_owner", System.currentTimeMillis());
continue;
}
ensureState(user);
Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(user.getBlockchainName(), q.rawDataItem());
if (result.ok()) {
importDAO.delete(q.dataItemId());
importedTotal++; progress = true;
continue;
}
String reason = result.reasonCode();
if ("bad_block_number".equals(reason) || "bad_prev_hash".equals(reason)
|| "prev_line_block_not_found".equals(reason) || "channel_not_found".equals(reason)
|| "chain_resync_in_progress".equals(reason)) {
importDAO.setPendingError(q.dataItemId(), reason, System.currentTimeMillis());
} else {
importDAO.reject(q.dataItemId(), reason, System.currentTimeMillis());
}
}
passes++;
} while (progress && passes < 8);
if (importedTotal > 0) log.info("Imported {} SHiNE test DataItems from Arweave", importedTotal);
}
private void ensureState(SolanaUserPdaCurrentEntry user) throws Exception {
BlockchainStateEntry existing = stateDAO.getByBlockchainName(user.getBlockchainName());
if (existing != null) return;
BlockchainStateEntry s = new BlockchainStateEntry();
s.setBlockchainName(user.getBlockchainName()); s.setLogin(user.getLogin()); s.setBlockchainKey(user.getBlockchainKey());
s.setSizeLimit(user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L);
s.setFileSizeBytes(0L); s.setLastBlockNumber(-1); s.setLastBlockHash(null); s.setUpdatedAtMs(System.currentTimeMillis());
stateDAO.insertIfMissing(s);
}
private JsonNode graphQlPage(long minHeight, String cursor) throws Exception {
String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\"";
String query = "query { transactions(tags:[{name:\"App\",values:[\"test5590\"]}], block:{min:" + minHeight + "}, first:" + cfg.syncPageSize() + ", after:" + after + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id bundledIn { id } block { height } } } } }";
String body = MAPPER.writeValueAsString(Map.of("query", query));
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql"))
.timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("GraphQL HTTP " + resp.statusCode());
return MAPPER.readTree(resp.body());
}
private byte[] downloadRootBundle(String txId) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/" + txId))
.timeout(Duration.ofMinutes(5)).GET().build();
HttpResponse<byte[]> resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Arweave root HTTP " + resp.statusCode() + " tx=" + txId);
byte[] body = resp.body();
if (body == null || body.length == 0) throw new IOException("Empty Arweave root bundle " + txId);
if (body.length > cfg.syncMaxRootBundleBytes()) throw new IOException("Root bundle exceeds syncMaxRootBundleBytes: " + body.length);
return body;
}
private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");}
private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");}
private static final class RootDownloadRuntimeException extends RuntimeException { RootDownloadRuntimeException(Throwable cause){super(cause);} }
}
@@ -0,0 +1,66 @@
package server.archive;
import utils.config.AppConfig;
import java.nio.file.Path;
/** Configuration of the new per-user-block ANS-104 Arweave transport. */
public record ArweaveBlocksConfig(
boolean publishEnabled,
int publishIntervalMinutes,
int publishMaxItems,
long publishMaxBundleBytes,
String publishGateway,
Path walletJwkPath,
int minConfirmations,
int confirmPollSeconds,
int confirmTimeoutMinutes,
boolean syncEnabled,
int syncIntervalMinutes,
int syncPageSize,
int syncQueueBatchSize,
long syncStartBlockHeight,
long syncMaxRootBundleBytes,
String syncGateway
) {
public static final String TEST_TAG_NAME = "App";
public static final String TEST_TAG_VALUE = "test5590";
public static final String CHANNEL_TAG_NAME = "c";
public static ArweaveBlocksConfig load() {
AppConfig c = AppConfig.getInstance();
return new ArweaveBlocksConfig(
c.getBoolean("arweave.blocks.publish.enabled", false),
positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"),
positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"),
positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"),
orDefault(c.getParam("arweave.blocks.publish.gateway"), "https://arweave.net"),
optionalPath(c.getParam("arweave.blocks.publish.walletJwkPath")),
nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"),
positive(c.getInt("arweave.blocks.publish.confirmPollSeconds", 30), "publish.confirmPollSeconds"),
positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"),
c.getBoolean("arweave.blocks.sync.enabled", false),
positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"),
clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100),
positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"),
nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"),
positiveLong(parseLong(c.getParam("arweave.blocks.sync.maxRootBundleBytes"), 256L * 1024 * 1024), "sync.maxRootBundleBytes"),
orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com")
);
}
public void validatePublisher() {
if (publishEnabled && walletJwkPath == null) {
throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required when publisher is enabled");
}
}
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
private static Path optionalPath(String v) { return v == null || v.isBlank() ? null : Path.of(v.trim()); }
private static long parseLong(String v, long d) { return v == null || v.isBlank() ? d : Long.parseLong(v.trim()); }
private static int positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
private static int nonNegative(int v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;}
private static long positiveLong(long v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
private static long nonNegativeLong(long v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;}
private static int clamp(int v,int min,int max){return Math.max(min,Math.min(max,v));}
}
@@ -0,0 +1,141 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.*;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PSSParameterSpec;
import java.security.spec.RSAPrivateCrtKeySpec;
import java.time.Duration;
import java.util.*;
/** Existing native Arweave L1 transport adapted to upload in-memory ANS-104 bundles. */
public final class ArweaveL1Uploader {
private static final Logger log = LoggerFactory.getLogger(ArweaveL1Uploader.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder B64D = Base64.getUrlDecoder();
public record Tag(String name, String value) {}
public record UploadResult(String txId, byte[] txIdBytes) {}
private final ArweaveBlocksConfig cfg;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
private volatile Wallet wallet;
public ArweaveL1Uploader(ArweaveBlocksConfig cfg) { this.cfg = Objects.requireNonNull(cfg); }
public UploadResult upload(byte[] data, List<Tag> tags) throws Exception {
if (data == null || data.length == 0) throw new IllegalArgumentException("Arweave upload data is empty");
Wallet w = wallet();
String gateway = trimGateway(cfg.publishGateway());
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(data);
String anchor = getRequiredText(gateway, "/tx_anchor");
String reward = getRequiredText(gateway, "/price/" + data.length);
if (!reward.matches("^\\d+$")) throw new IOException("Bad Arweave price: " + reward);
verifyBalance(gateway, w.address(), reward);
List<List<byte[]>> tagList = new ArrayList<>();
List<Map<String,String>> jsonTags = new ArrayList<>();
for (Tag tag : tags == null ? List.<Tag>of() : tags) addTag(jsonTags, tagList, tag.name(), tag.value());
byte[] signaturePayload = deepHash(List.of(
utf8("2"), b64Decode(w.owner()), new byte[0], utf8("0"), utf8(reward),
b64Decode(anchor), tagList, utf8(Integer.toString(data.length)), merkle.dataRoot()
));
byte[] rawSignature = signPayload(w.privateKey(), signaturePayload);
byte[] txIdBytes = sha256(rawSignature);
String txId = b64(txIdBytes);
Map<String,Object> tx = new LinkedHashMap<>();
tx.put("format",2); tx.put("id",txId); tx.put("last_tx",anchor); tx.put("owner",w.owner());
tx.put("tags",jsonTags); tx.put("target",""); tx.put("quantity","0");
tx.put("data_root",b64(merkle.dataRoot())); tx.put("data_size",Integer.toString(data.length)); tx.put("data","");
tx.put("reward",reward); tx.put("signature",b64(rawSignature));
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
for (int i=0;i<merkle.chunks().size();i++) {
ArweaveMerkle.Chunk meta=merkle.chunks().get(i); ArweaveMerkle.Proof proof=merkle.proofs().get(i);
Map<String,Object> body=new LinkedHashMap<>();
body.put("data_root",b64(merkle.dataRoot())); body.put("data_size",Integer.toString(data.length));
body.put("data_path",b64(proof.proof())); body.put("offset",Long.toString(proof.offset()));
body.put("chunk",b64(ArweaveMerkle.readChunk(data,meta)));
postChunkWithRetry(gateway,body,i,merkle.chunks().size());
}
if (cfg.minConfirmations() > 0) waitForConfirmations(txId);
log.info("ANS-104 bundle uploaded to Arweave: tx={} bytes={} items-transported",txId,data.length);
return new UploadResult(txId,txIdBytes);
}
private int waitForConfirmations(String txId) throws Exception {
String gateway=trimGateway(cfg.publishGateway());
long deadline=System.currentTimeMillis()+cfg.confirmTimeoutMinutes()*60_000L; int last=0;
while(System.currentTimeMillis()<deadline){
HttpRequest req=HttpRequest.newBuilder(URI.create(gateway+"/tx/"+txId+"/status")).timeout(Duration.ofSeconds(20)).GET().build();
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if(resp.statusCode()==200){JsonNode j=MAPPER.readTree(resp.body());last=j.path("number_of_confirmations").asInt(0);if(last>=cfg.minConfirmations())return last;}
else if(resp.statusCode()!=202&&resp.statusCode()!=404)log.warn("Arweave status tx={} HTTP {}",txId,resp.statusCode());
Thread.sleep(cfg.confirmPollSeconds()*1000L);
}
throw new IOException("Arweave confirmation timeout for "+txId+", last="+last);
}
private void postChunkWithRetry(String gateway,Map<String,Object> body,int index,int total)throws Exception{
Exception last=null;
for(int attempt=1;attempt<=12;attempt++)try{postJsonExpect2xx(gateway+"/chunk",body,Duration.ofSeconds(60));return;}catch(Exception e){last=e;if(attempt==12)break;Thread.sleep(5000L);}
throw new IOException("Failed Arweave chunk "+(index+1)+"/"+total,last);
}
private void postJsonExpect2xx(String url,Object body,Duration timeout)throws Exception{
HttpRequest req=HttpRequest.newBuilder(URI.create(url)).timeout(timeout).header("Content-Type","application/json").header("Accept","application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(body),StandardCharsets.UTF_8)).build();
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("HTTP "+resp.statusCode()+" "+safe(resp.body()));
}
private void verifyBalance(String gateway,String address,String reward)throws Exception{
String balance=getRequiredText(gateway,"/wallet/"+address+"/balance");
if(balance.matches("^\\d+$")&&new BigInteger(balance).compareTo(new BigInteger(reward))<0)throw new IllegalStateException("Not enough AR: balance="+balance+" reward="+reward);
}
private String getRequiredText(String gateway,String path)throws Exception{
HttpRequest req=HttpRequest.newBuilder(URI.create(gateway+path)).timeout(Duration.ofSeconds(20)).GET().build();
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("Arweave HTTP "+resp.statusCode()+" for "+path+": "+safe(resp.body()));
String s=resp.body()==null?"":resp.body().trim();if(s.isBlank())throw new IOException("Empty Arweave response for "+path);return s;
}
private Wallet wallet()throws Exception{
Wallet local=wallet;if(local!=null)return local;synchronized(this){if(wallet!=null)return wallet;
JsonNode jwk=MAPPER.readTree(Files.readString(cfg.walletJwkPath(),StandardCharsets.UTF_8));String owner=required(jwk,"n");
PrivateKey pk=buildPrivateKey(jwk);String address=b64(sha256(b64Decode(owner)));return wallet=new Wallet(owner,address,pk);}
}
private static PrivateKey buildPrivateKey(JsonNode jwk)throws GeneralSecurityException{
RSAPrivateCrtKeySpec spec=new RSAPrivateCrtKeySpec(big(required(jwk,"n")),big(required(jwk,"e")),big(required(jwk,"d")),big(required(jwk,"p")),big(required(jwk,"q")),big(required(jwk,"dp")),big(required(jwk,"dq")),big(required(jwk,"qi")));
return KeyFactory.getInstance("RSA").generatePrivate(spec);
}
private static byte[] signPayload(PrivateKey key,byte[] payload)throws GeneralSecurityException{
Signature s=Signature.getInstance("RSASSA-PSS");s.setParameter(new PSSParameterSpec("SHA-256","MGF1",MGF1ParameterSpec.SHA256,32,1));s.initSign(key);s.update(payload);return s.sign();
}
private static byte[] deepHash(Object data)throws GeneralSecurityException{
if(data instanceof List<?> list){byte[] acc=sha384(utf8("list"+list.size()));for(Object item:list)acc=sha384(concat(acc,deepHash(item)));return acc;}
if(!(data instanceof byte[] bytes))throw new IllegalArgumentException("deepHash byte[]/List expected");return sha384(concat(sha384(utf8("blob"+bytes.length)),sha384(bytes)));
}
private static void addTag(List<Map<String,String>> json,List<List<byte[]>> raw,String name,String value){byte[]n=utf8(name),v=utf8(value);Map<String,String>m=new LinkedHashMap<>();m.put("name",b64(n));m.put("value",b64(v));json.add(m);raw.add(List.of(n,v));}
private static String required(JsonNode n,String f){String s=n==null?"":n.path(f).asText("").trim();if(s.isBlank())throw new IllegalStateException("Arweave JWK missing "+f);return s;}
private static BigInteger big(String s){return new BigInteger(1,b64Decode(s));}
private static String trimGateway(String s){return(s==null?"https://arweave.net":s.trim()).replaceAll("/+$","");}
private static byte[] utf8(String s){return String.valueOf(s==null?"":s).getBytes(StandardCharsets.UTF_8);}
private static byte[] b64Decode(String s){return B64D.decode(s);}private static String b64(byte[]b){return B64.encodeToString(b);}
private static byte[] sha256(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-256").digest(b);}private static byte[] sha384(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-384").digest(b);}
private static byte[] concat(byte[]...a){int n=0;for(byte[]x:a)n=Math.addExact(n,x.length);byte[]o=new byte[n];int p=0;for(byte[]x:a){System.arraycopy(x,0,o,p,x.length);p+=x.length;}return o;}
private static String safe(String s){s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return s.length()<=250?s:s.substring(0,250)+"...";}
private record Wallet(String owner,String address,PrivateKey privateKey){}
}
@@ -29,6 +29,55 @@ final class ArweaveMerkle {
private ArweaveMerkle() {}
static Prepared prepare(byte[] data) throws IOException {
if (data == null || data.length == 0) throw new IOException("Arweave data is empty");
List<Chunk> chunks = chunkBytes(data);
List<Node> leaves = new ArrayList<>(chunks.size());
for (Chunk c : chunks) {
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
}
Node root = buildLayers(leaves);
List<Proof> proofs = new ArrayList<>();
resolveProofs(root, new byte[0], proofs);
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
chunks = new ArrayList<>(chunks);
chunks.remove(chunks.size() - 1);
proofs.remove(proofs.size() - 1);
}
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
}
private static List<Chunk> chunkBytes(byte[] data) {
List<Chunk> out = new ArrayList<>();
long remaining = data.length;
long cursor = 0;
int p = 0;
while (remaining >= MAX_CHUNK_SIZE) {
long chunkSize = MAX_CHUNK_SIZE;
long next = remaining - MAX_CHUNK_SIZE;
if (next > 0 && next < MIN_CHUNK_SIZE) chunkSize = (remaining + 1) / 2;
byte[] chunk = Arrays.copyOfRange(data, p, p + (int) chunkSize);
out.add(new Chunk(sha256(chunk), cursor, cursor + chunkSize));
p += (int) chunkSize;
cursor += chunkSize;
remaining -= chunkSize;
}
byte[] rest = Arrays.copyOfRange(data, p, p + (int) remaining);
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
return out;
}
static byte[] readChunk(byte[] data, Chunk c) throws IOException {
long len = c.size();
if (data == null || len < 0 || len > Integer.MAX_VALUE || c.minByteRange() < 0 || c.maxByteRange() > data.length) {
throw new IOException("Invalid Arweave chunk range");
}
return Arrays.copyOfRange(data, (int) c.minByteRange(), (int) c.maxByteRange());
}
static Prepared prepare(Path file) throws IOException {
long fileSize = Files.size(file);
List<Chunk> chunks = chunkFile(file, fileSize);
@@ -1,101 +0,0 @@
package server.archive;
import sync.util.Base58Util;
import utils.crypto.Ed25519Util;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.MessageDigest;
import java.util.*;
/** Строгий reader/verifier формата SHINE-ARCHIVE v1.0. */
public final class ShineArchiveReader {
public record Reference(long bigBlockNumber, byte[] archiveHash, String arweaveTxId) {}
public record Chunk(long offset, long size, String blockchainName, List<byte[]> rawRecords,
long previousBigBlockRef, long previousChunkOffset, long previousChunkSize) {}
public record Document(long bigBlockNumber, long createdAtMs, String creatorLogin,
List<Reference> references, List<Chunk> chunks, byte[] archiveHash) {}
public Document readAndVerify(Path file, String expectedPublisherLogin, byte[] expectedHash, String rootPublicKeyBase58) throws Exception {
long fileSize = Files.size(file);
if (fileSize < 13 + 38 + 1 + 32 + 64) throw new IOException("SHINE-ARCHIVE слишком короткий");
if (fileSize >= 0x1_0000_0000L) throw new IOException("SHINE-ARCHIVE v1 >= 4 GiB не поддерживается");
byte[] computedHash = digestPrefix(file, fileSize - 96);
try (RandomAccessFile in = new RandomAccessFile(file.toFile(), "r")) {
byte[] magic = new byte[ShineArchiveWriter.MAGIC.length];
in.readFully(magic);
if (!Arrays.equals(magic, ShineArchiveWriter.MAGIC)) throw new IOException("Bad SHINE-ARCHIVE magic");
int major=in.readUnsignedByte(), minor=in.readUnsignedByte();
if (major != 1 || minor != 0) throw new IOException("Unsupported SHINE-ARCHIVE version " + major + "." + minor);
long headerSize=u32(in); long bigBlock=u32(in); long createdAt=in.readLong();
int creatorLen=in.readUnsignedByte(); int referencesMode=in.readUnsignedByte();
long refsCount=u32(in); int refSize=in.readUnsignedShort(); long parentRef=u32(in);
long chunksCount=u32(in); long recordsCount=u32(in);
if (referencesMode != ShineArchiveWriter.REFERENCES_FULL || refSize != ShineArchiveWriter.REFERENCE_ENTRY_SIZE) {
throw new IOException("Unsupported references table");
}
String creator=readUtf8(in,creatorLen);
if (!creator.equalsIgnoreCase(String.valueOf(expectedPublisherLogin))) {
throw new SecurityException("Archive creator " + creator + " != trusted publisher " + expectedPublisherLogin);
}
if (headerSize != in.getFilePointer()) throw new IOException("Некорректный header_size");
if (refsCount > 10_000_000L || chunksCount > 10_000_000L) throw new IOException("Слишком много refs/chunks");
List<Reference> refs=new ArrayList<>((int)Math.min(refsCount, Integer.MAX_VALUE));
for(long i=0;i<refsCount;i++) {
long number=u32(in); byte[] hash=readFixed(in,32); byte[] tx=readFixed(in,32);
refs.add(new Reference(number,hash,Base64.getUrlEncoder().withoutPadding().encodeToString(tx)));
}
if (refs.isEmpty() && parentRef != ShineArchiveWriter.NO_REFERENCE_U32) throw new IOException("Некорректный parent reference");
if (!refs.isEmpty() && parentRef >= refs.size()) throw new IOException("parent_reference_index вне таблицы");
List<Chunk> chunks=new ArrayList<>((int)Math.min(chunksCount, Integer.MAX_VALUE));
long parsedRecords=0;
for(long ci=0;ci<chunksCount;ci++) {
long chunkOffset=in.getFilePointer(); long chunkSize=u32(in); long chunkEnd=chunkOffset+chunkSize;
if (chunkSize < 21 || chunkEnd > fileSize-96) throw new IOException("Некорректный chunk_size");
int nameLen=in.readUnsignedByte(); String bch=readUtf8(in,nameLen); long count=u32(in);
if (count > 10_000_000L) throw new IOException("Слишком много records в chunk " + bch);
List<byte[]> records=new ArrayList<>((int)Math.min(count,Integer.MAX_VALUE));
for(long ri=0;ri<count;ri++) {
long n=u32(in); if(n<=0 || n>Integer.MAX_VALUE || in.getFilePointer()+n>chunkEnd-12) throw new IOException("Некорректный record_size");
records.add(readFixed(in,(int)n)); parsedRecords++;
}
long prevRef=u32(in), prevOffset=u32(in), prevSize=u32(in);
if (prevRef != ShineArchiveWriter.NO_REFERENCE_U32 && prevRef >= refs.size()) throw new IOException("previous_big_block_ref вне таблицы");
if (in.getFilePointer()!=chunkEnd) throw new IOException("Chunk framing mismatch: " + bch);
chunks.add(new Chunk(chunkOffset,chunkSize,bch,List.copyOf(records),prevRef,prevOffset,prevSize));
}
if (parsedRecords != recordsCount) throw new IOException("total_user_records_count mismatch");
int closerLen=in.readUnsignedByte(); String closer=readUtf8(in,closerLen);
if(!closer.equals(creator)) throw new SecurityException("creator_login != closer_login");
if(in.getFilePointer()!=fileSize-96) throw new IOException("Некорректная граница footer/hash");
byte[] storedHash=readFixed(in,32); byte[] signature=readFixed(in,64);
if(in.getFilePointer()!=fileSize) throw new IOException("Лишние данные после signature");
if(!Arrays.equals(storedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с footer");
if(expectedHash!=null && !Arrays.equals(expectedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с ожидаемым hash");
byte[] root=Base58Util.decode(rootPublicKeyBase58);
if(root.length!=32) throw new SecurityException("Некорректный root public key publisher-а");
byte[] signed=new byte[ShineArchiveWriter.SIGNATURE_DOMAIN.length+32];
System.arraycopy(ShineArchiveWriter.SIGNATURE_DOMAIN,0,signed,0,ShineArchiveWriter.SIGNATURE_DOMAIN.length);
System.arraycopy(computedHash,0,signed,ShineArchiveWriter.SIGNATURE_DOMAIN.length,32);
if(!Ed25519Util.verify(signed,signature,root)) throw new SecurityException("Не прошла подпись SHINE-ARCHIVE publisher-а");
return new Document(bigBlock,createdAt,creator,List.copyOf(refs),List.copyOf(chunks),computedHash);
}
}
private static byte[] digestPrefix(Path file,long bytes) throws Exception {
MessageDigest md=MessageDigest.getInstance("SHA-256");
try(InputStream in=new BufferedInputStream(Files.newInputStream(file))) {
byte[] buf=new byte[1024*1024]; long left=bytes;
while(left>0){int n=in.read(buf,0,(int)Math.min(buf.length,left)); if(n<0) throw new EOFException(); md.update(buf,0,n); left-=n;}
}
return md.digest();
}
private static long u32(RandomAccessFile in) throws IOException { return Integer.toUnsignedLong(in.readInt()); }
private static byte[] readFixed(RandomAccessFile in,int n) throws IOException { byte[] b=new byte[n]; in.readFully(b); return b; }
private static String readUtf8(RandomAccessFile in,int n) throws IOException { return new String(readFixed(in,n), StandardCharsets.UTF_8); }
}
@@ -1,310 +0,0 @@
package server.archive;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.entities.BlockEntry;
import utils.crypto.Ed25519Util;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
/**
* Writer бинарного SHINE-ARCHIVE v1.0.
*
* Один blockchain_name встречается максимум один раз и содержит все новые raw records этой цепочки.
*/
public final class ShineArchiveWriter {
public static final byte VERSION_MAJOR = 1;
public static final byte VERSION_MINOR = 0;
public static final byte REFERENCES_FULL = 0;
public static final long NO_REFERENCE_U32 = 0xffff_ffffL;
public static final int REFERENCE_ENTRY_SIZE = 68;
public static final byte[] MAGIC = "SHINE-ARCHIVE".getBytes(StandardCharsets.US_ASCII);
public static final byte[] SIGNATURE_DOMAIN = "SHINE-ARCHIVE-V1".getBytes(StandardCharsets.US_ASCII);
public record ChunkPosition(long offset, long size) {}
public record BuildResult(Path file, byte[] archiveHash, Map<String, ChunkPosition> chunks, long fileSize) {}
public BuildResult build(
Path finalPath,
String creatorLogin,
long bigBlockNumber,
long createdAtMs,
List<ArchiveBigBlockRef> previousBigBlocks,
List<ArchivePublishJobChain> chains,
Map<String, List<BlockEntry>> recordsByBlockchain,
byte[] signingPrivateSeed32,
long maxFileBytes
) throws IOException {
Objects.requireNonNull(finalPath, "finalPath");
Objects.requireNonNull(previousBigBlocks, "previousBigBlocks");
Objects.requireNonNull(chains, "chains");
Objects.requireNonNull(recordsByBlockchain, "recordsByBlockchain");
requireU32(bigBlockNumber, "big_block_number");
if (signingPrivateSeed32 == null || signingPrivateSeed32.length != 32) {
throw new IllegalArgumentException("Archive signing key должен быть 32-byte Ed25519 seed");
}
byte[] loginBytes = utf8U8(creatorLogin, "creator_login");
if (previousBigBlocks.size() > 0xffff_ffffL || chains.size() > 0xffff_ffffL) {
throw new IllegalArgumentException("Слишком много references/chunks для v1");
}
List<ArchivePublishJobChain> sortedChains = new ArrayList<>(chains);
sortedChains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
long totalRecords = 0;
for (ArchivePublishJobChain ch : sortedChains) {
List<BlockEntry> rs = recordsByBlockchain.get(ch.blockchainName());
if (rs == null || rs.isEmpty()) {
throw new IllegalArgumentException("Нет frozen records для " + ch.blockchainName());
}
totalRecords += rs.size();
requireU32(totalRecords, "total_user_records_count");
}
Map<Long, Integer> referenceIndexByBlock = new HashMap<>();
for (int i = 0; i < previousBigBlocks.size(); i++) {
ArchiveBigBlockRef ref = previousBigBlocks.get(i);
requireU32(ref.bigBlockNumber(), "PreviousBigBlockReference.big_block_number");
require32(ref.archiveHash(), "PreviousBigBlockReference.big_block_hash");
require32(ref.arweaveTxId(), "PreviousBigBlockReference.arweave_tx_id");
referenceIndexByBlock.put(ref.bigBlockNumber(), i);
}
Files.createDirectories(finalPath.toAbsolutePath().getParent());
Path tmp = finalPath.resolveSibling(finalPath.getFileName() + ".tmp");
Files.deleteIfExists(tmp);
Map<String, ChunkPosition> positions = new LinkedHashMap<>();
MessageDigest digest = sha256();
try (OutputStream fileOut = new BufferedOutputStream(Files.newOutputStream(tmp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE));
CountingOutputStream counting = new CountingOutputStream(fileOut);
DigestOutputStream digestOut = new DigestOutputStream(counting, digest);
DataOutputStream out = new DataOutputStream(digestOut)) {
// ---------- header ----------
out.write(MAGIC);
out.writeByte(VERSION_MAJOR);
out.writeByte(VERSION_MINOR);
long headerSize = fixedHeaderSize() + loginBytes.length;
writeU32(out, headerSize);
writeU32(out, bigBlockNumber);
out.writeLong(createdAtMs);
out.writeByte(loginBytes.length);
out.writeByte(REFERENCES_FULL);
writeU32(out, previousBigBlocks.size());
out.writeShort(REFERENCE_ENTRY_SIZE);
long parentIndex = previousBigBlocks.isEmpty() ? NO_REFERENCE_U32 : previousBigBlocks.size() - 1L;
writeU32(out, parentIndex);
writeU32(out, sortedChains.size());
writeU32(out, totalRecords);
out.write(loginBytes);
// ---------- полный список предыдущих больших блоков ----------
for (ArchiveBigBlockRef ref : previousBigBlocks) {
writeU32(out, ref.bigBlockNumber());
out.write(ref.archiveHash());
out.write(ref.arweaveTxId());
}
// ---------- по одному chunk на blockchain_name ----------
for (ArchivePublishJobChain ch : sortedChains) {
List<BlockEntry> records = new ArrayList<>(recordsByBlockchain.get(ch.blockchainName()));
records.sort(Comparator.comparingInt(BlockEntry::getBlockNumber));
validateFrozenRange(ch, records);
byte[] name = utf8U8(ch.blockchainName(), "blockchain_name");
long chunkSize = calculateChunkSize(name.length, records);
requireU32(chunkSize, "chunk_size");
long chunkOffset = counting.getCount();
requireU32(chunkOffset, "chunk_offset");
if (chunkOffset + chunkSize > maxFileBytes) {
throw new ArchiveTooLargeException("Большой архив превысит archive.maxFileBytes: " + (chunkOffset + chunkSize));
}
writeU32(out, chunkSize);
out.writeByte(name.length);
out.write(name);
writeU32(out, records.size());
for (BlockEntry record : records) {
byte[] raw = record.getBlockBytes();
if (raw == null) throw new IllegalArgumentException("block_bytes=NULL: " + ch.blockchainName() + "#" + record.getBlockNumber());
writeU32(out, raw.length);
out.write(raw);
}
if (ch.previousArchiveBigBlockNumber() == null) {
writeU32(out, NO_REFERENCE_U32);
writeU32(out, 0);
writeU32(out, 0);
} else {
Integer refIndex = referenceIndexByBlock.get(ch.previousArchiveBigBlockNumber());
if (refIndex == null) {
throw new IllegalStateException("Предыдущий big block blockchain отсутствует в FULL reference table: "
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
}
ArchiveBigBlockRef previousRef = previousBigBlocks.get(refIndex);
require32(ch.previousArchiveBigBlockHash(), "previous_archive_big_block_hash " + ch.blockchainName());
if (!Arrays.equals(previousRef.archiveHash(), ch.previousArchiveBigBlockHash())) {
throw new IllegalStateException("Hash предыдущего big block не совпадает с FULL reference table: "
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
}
if (ch.previousChunkOffset() == null || ch.previousChunkSize() == null) {
throw new IllegalStateException("Нет предыдущих chunk offset/size: " + ch.blockchainName());
}
writeU32(out, refIndex);
writeU32(out, ch.previousChunkOffset());
writeU32(out, ch.previousChunkSize());
}
positions.put(ch.blockchainName(), new ChunkPosition(chunkOffset, chunkSize));
}
// ---------- footer, login входит в hash ----------
out.writeByte(loginBytes.length);
out.write(loginBytes);
out.flush();
byte[] archiveHash = digest.digest();
byte[] signedPayload = new byte[SIGNATURE_DOMAIN.length + archiveHash.length];
System.arraycopy(SIGNATURE_DOMAIN, 0, signedPayload, 0, SIGNATURE_DOMAIN.length);
System.arraycopy(archiveHash, 0, signedPayload, SIGNATURE_DOMAIN.length, archiveHash.length);
byte[] signature = Ed25519Util.sign(signedPayload, signingPrivateSeed32);
// hash/signature не должны попасть в hash самого блока.
digestOut.on(false);
out.write(archiveHash);
out.write(signature);
out.flush();
long finalSize = counting.getCount();
if (finalSize >= 0x1_0000_0000L || finalSize > maxFileBytes) {
throw new ArchiveTooLargeException("Размер SHINE-ARCHIVE v1 превышает лимит: " + finalSize);
}
} catch (Exception e) {
Files.deleteIfExists(tmp);
if (e instanceof IOException ioe) throw ioe;
throw new IOException("Не удалось собрать SHINE-ARCHIVE", e);
}
// До Arweave upload существует уже готовый локальный файл с окончательным именем.
try {
Files.move(tmp, finalPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, finalPath, StandardCopyOption.REPLACE_EXISTING);
}
byte[] hash = readStoredArchiveHash(finalPath);
long size = Files.size(finalPath);
return new BuildResult(finalPath, hash, Collections.unmodifiableMap(positions), size);
}
/** Проверяет локальный файл перед оплатой Arweave. */
public static byte[] verifyLocalFile(Path file) throws IOException {
long size = Files.size(file);
if (size < MAGIC.length + 2 + 96) throw new IOException("Слишком короткий SHINE-ARCHIVE: " + file);
byte[] stored = readStoredArchiveHash(file);
MessageDigest md = sha256();
long hashInputSize = size - 96; // [archive_hash 32][signature 64]
try (InputStream in = Files.newInputStream(file)) {
byte[] buf = new byte[1024 * 1024];
long left = hashInputSize;
while (left > 0) {
int n = in.read(buf, 0, (int)Math.min(buf.length, left));
if (n < 0) throw new EOFException("Неожиданный EOF при проверке архива");
md.update(buf, 0, n);
left -= n;
}
}
byte[] actual = md.digest();
if (!Arrays.equals(stored, actual)) throw new IOException("SHA-256 локального SHINE-ARCHIVE не совпадает с footer");
return actual;
}
public static byte[] readStoredArchiveHash(Path file) throws IOException {
long size = Files.size(file);
if (size < 96) throw new EOFException("Файл слишком короткий");
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
raf.seek(size - 96);
byte[] hash = new byte[32];
raf.readFully(hash);
return hash;
}
}
private static long fixedHeaderSize() {
// magic + major/minor + headerSize + blockNo + time + creatorLen + mode + refCount + refSize + parentIndex + chunkCount + recordCount
return 13L + 1 + 1 + 4 + 4 + 8 + 1 + 1 + 4 + 2 + 4 + 4 + 4;
}
private static long calculateChunkSize(int nameLen, List<BlockEntry> records) {
long size = 4L + 1L + nameLen + 4L + 12L;
for (BlockEntry e : records) {
byte[] raw = e.getBlockBytes();
if (raw == null) throw new IllegalArgumentException("raw record is null");
size += 4L + raw.length;
}
return size;
}
private static void validateFrozenRange(ArchivePublishJobChain ch, List<BlockEntry> records) {
long expected = ch.toSourceBlockNumber() - ch.fromSourceBlockNumber() + 1;
if (expected <= 0 || expected != records.size()) {
throw new IllegalArgumentException("Frozen range неполон для " + ch.blockchainName()
+ ": ожидалось=" + expected + ", получено=" + records.size());
}
long n = ch.fromSourceBlockNumber();
for (BlockEntry e : records) {
if (Integer.toUnsignedLong(e.getBlockNumber()) != n) {
throw new IllegalArgumentException("Нарушена непрерывность " + ch.blockchainName()
+ ": ожидался block " + n + ", получен " + Integer.toUnsignedLong(e.getBlockNumber()));
}
n++;
}
}
private static byte[] utf8U8(String value, String name) {
if (value == null) throw new IllegalArgumentException(name + " == null");
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0 || bytes.length > 255) throw new IllegalArgumentException(name + " должен занимать 1..255 UTF-8 байт");
return bytes;
}
private static void require32(byte[] v, String name) {
if (v == null || v.length != 32) throw new IllegalArgumentException(name + " должен быть 32 bytes");
}
private static void requireU32(long v, String name) {
if (v < 0 || v > 0xffff_ffffL) throw new IllegalArgumentException(name + " вне u32: " + v);
}
private static void writeU32(DataOutputStream out, long v) throws IOException {
requireU32(v, "u32");
out.writeInt((int)v); // DataOutputStream = big-endian; битовый шаблон корректен и для unsigned > 2^31.
}
private static MessageDigest sha256() {
try { return MessageDigest.getInstance("SHA-256"); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
/** OutputStream со счётчиком без ограничения int. */
private static final class CountingOutputStream extends FilterOutputStream {
private long count;
CountingOutputStream(OutputStream out) { super(out); }
long getCount() { return count; }
@Override public void write(int b) throws IOException { out.write(b); count++; }
@Override public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); count += len; }
}
public static final class ArchiveTooLargeException extends IOException {
public ArchiveTooLargeException(String message) { super(message); }
}
}
@@ -1,373 +0,0 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sync.codec.ShineUsersCodec;
import sync.codec.ShineUsersCodec.UserPdaSnapshot;
import sync.util.Base58Util;
import sync.util.SolanaPdaUtil;
import utils.crypto.Ed25519Util;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.*;
/**
* Обновляет ArchiveHeadBlock (type=100) обычной инструкцией update_user_pda.
* Отдельной Solana-инструкции для архива нет.
*/
public final class SolanaArchiveHeadWriter {
private static final Logger log = LoggerFactory.getLogger(SolanaArchiveHeadWriter.class);
private static final ObjectMapper JSON = new ObjectMapper();
private static final String SYSTEM_PROGRAM = "11111111111111111111111111111111";
private static final String ED25519_PROGRAM = "Ed25519SigVerify111111111111111111111111111";
private static final String COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111";
private static final String SYSVAR_INSTRUCTIONS = "Sysvar1nstructions1111111111111111111111111";
private static final String USER_SEED_PREFIX = "user_login=";
private static final String ECONOMY_SEED = "shine_users_economy_config";
private static final String INFLOW_SEED = "shine_payments_inflow_vault";
private static final byte[] LAST_BLOCK_STATE_PREFIX = "SHiNE_LAST_BLOCK".getBytes(StandardCharsets.UTF_8);
private final ArchivePublisherConfig cfg;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
private final byte[] rootSeed32;
private final byte[] clientSeed32;
public SolanaArchiveHeadWriter(ArchivePublisherConfig cfg) throws IOException {
this.cfg = Objects.requireNonNull(cfg);
this.rootSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
this.clientSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaClientKeyPath());
}
public record SubmitResult(String signature, String userPda) {}
/** Preflight: проверяет, что локальные root/client private keys соответствуют текущему User PDA. */
public void validateKeysAgainstPda() throws Exception {
Current current = loadCurrent();
byte[] expectedRoot = Base58Util.decode(current.snapshot.rootKey());
byte[] expectedClient = Base58Util.decode(current.snapshot.clientKey());
if (!Arrays.equals(expectedRoot, Ed25519Util.derivePublicKey(rootSeed32))) {
throw new IllegalStateException("archive.solana.rootKeyPath не соответствует root_key User PDA " + cfg.serverLogin());
}
if (!Arrays.equals(expectedClient, Ed25519Util.derivePublicKey(clientSeed32))) {
throw new IllegalStateException("archive.solana.clientKeyPath не соответствует client_key User PDA " + cfg.serverLogin());
}
}
public SubmitResult submitArchiveHead(byte[] arweaveTxId32, byte[] archiveHash32) throws Exception {
require32(arweaveTxId32, "Arweave TX ID");
require32(archiveHash32, "archive hash");
Current current = loadCurrent();
UserPdaSnapshot old = current.snapshot;
byte[] rootPub = Ed25519Util.derivePublicKey(rootSeed32);
byte[] clientPub = Ed25519Util.derivePublicKey(clientSeed32);
if (!Arrays.equals(rootPub, Base58Util.decode(old.rootKey()))) {
throw new IllegalStateException("Root key archive publisher-а не совпадает с User PDA");
}
if (!Arrays.equals(clientPub, Base58Util.decode(old.clientKey()))) {
throw new IllegalStateException("Client key archive publisher-а не совпадает с User PDA");
}
byte[] currentRaw = Base64.getDecoder().decode(old.rawDataBase64());
int currentRecordLen = u16le(currentRaw, 7);
byte[] currentUnsigned = Arrays.copyOf(currentRaw, currentRecordLen - 64);
byte[] prevHash = sha256(currentUnsigned);
long updatedAt = System.currentTimeMillis();
String txB64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(arweaveTxId32);
String archiveHashHex = hex(archiveHash32);
String zeroSig = Base58Util.encode(new byte[64]);
UserPdaSnapshot nextUnsignedSnapshot = new UserPdaSnapshot(
old.pdaAddress(), old.login(), old.recordNumber() + 1, old.slot(), "",
old.recoveryKey(), old.rootKey(), old.clientKey(), old.blockchainName(), old.blockchainKey(),
old.paidLimitBytes(), old.usedBytes(), old.lastBlockNumber(), old.lastBlockHash(),
old.lastBlockSignature(), old.arweaveTxId(), txB64Url, archiveHashHex,
old.isServer(), old.addressFormatType(), old.addressFormatVersion(), old.serverAddress(),
old.syncServers(), old.accessServers(), old.sessionsMode(), old.sessions(), old.trustedCount(),
old.createdAtMs(), updatedAt, hex(prevHash), zeroSig, ""
);
byte[] nextFull = Base64.getDecoder().decode(ShineUsersCodec.serializeSnapshotToBase64(nextUnsignedSnapshot));
int nextRecordLen = u16le(nextFull, 7);
byte[] nextUnsigned = Arrays.copyOf(nextFull, nextRecordLen - 64);
byte[] nextUnsignedHash = sha256(nextUnsigned);
byte[] rootSignature = Ed25519Util.sign(nextUnsignedHash, rootSeed32);
byte[] updateData = buildUpdateInstruction(old, updatedAt, prevHash, arweaveTxId32, archiveHash32, rootSignature);
byte[] lastStateHash = sha256(buildLastBlockState(old));
byte[] lastBlockSignature = Base58Util.decode(old.lastBlockSignature());
byte[] blockchainPub = Base58Util.decode(old.blockchainKey());
byte[] rootEdData = buildEd25519Instruction(rootSignature, rootPub, nextUnsignedHash);
byte[] bchEdData = buildEd25519Instruction(lastBlockSignature, blockchainPub, lastStateHash);
String blockhash = getLatestBlockhash();
byte[] message = buildLegacyMessage(current.userPda, updateData, rootEdData, bchEdData, blockhash, clientPub);
byte[] clientSignature = Ed25519Util.sign(message, clientSeed32);
byte[] transaction = serializeTransaction(clientSignature, message);
String signature = sendTransaction(transaction);
log.info("Archive head отправлен в Solana: login={} pda={} signature={}", cfg.serverLogin(), current.userPda, signature);
return new SubmitResult(signature, current.userPda);
}
public void waitFinalized(String signature) throws Exception {
long deadline = System.currentTimeMillis() + cfg.solanaConfirmTimeoutMinutes() * 60_000L;
while (System.currentTimeMillis() < deadline) {
JsonNode result = rpc("getSignatureStatuses", List.of(List.of(signature), Map.of("searchTransactionHistory", true))).path("result");
JsonNode values = result.path("value");
JsonNode status = values.isArray() && values.size() > 0 ? values.get(0) : null;
if (status != null && !status.isNull()) {
if (status.hasNonNull("err")) {
throw new IOException("Solana archive-head tx завершилась ошибкой: " + status.path("err"));
}
String confirmation = status.path("confirmationStatus").asText("");
if ("finalized".equals(confirmation)) return;
}
Thread.sleep(cfg.solanaConfirmPollSeconds() * 1000L);
}
throw new IOException("Timeout ожидания Solana finalized: " + signature);
}
public boolean currentHeadMatches(byte[] txId32, byte[] hash32) throws Exception {
Current current = loadCurrent();
if (current.snapshot.archiveHeadTxId() == null || current.snapshot.archiveHeadTxId().isBlank()) return false;
byte[] currentTx = Base64.getUrlDecoder().decode(current.snapshot.archiveHeadTxId());
byte[] currentHash = unhex(current.snapshot.archiveHeadHash());
return Arrays.equals(currentTx, txId32) && Arrays.equals(currentHash, hash32);
}
private Current loadCurrent() throws Exception {
String normalized = cfg.serverLogin().trim().toLowerCase(Locale.ROOT);
String userPda = SolanaPdaUtil.findProgramAddress(
List.of(USER_SEED_PREFIX.getBytes(StandardCharsets.UTF_8), normalized.getBytes(StandardCharsets.UTF_8)),
cfg.solanaUsersProgramId());
JsonNode accountResult = rpc("getAccountInfo", List.of(userPda, Map.of("encoding", "base64", "commitment", cfg.solanaCommitment()))).path("result");
JsonNode value = accountResult.path("value");
if (value.isMissingNode() || value.isNull()) throw new IOException("User PDA не найден для " + cfg.serverLogin());
String owner = value.path("owner").asText("");
if (!cfg.solanaUsersProgramId().equals(owner)) throw new IOException("User PDA принадлежит другой программе: " + owner);
JsonNode data = value.path("data");
if (!data.isArray() || data.size() < 1) throw new IOException("Некорректный getAccountInfo.data");
String rawB64 = data.get(0).asText();
UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(userPda, 0L, rawB64, "");
return new Current(userPda, snapshot);
}
private byte[] buildUpdateInstruction(UserPdaSnapshot s, long updatedAt, byte[] prevHash,
byte[] archiveTx, byte[] archiveHash, byte[] rootSig) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(4); // IX_UPDATE_USER_PDA
strU8(out, s.login());
out.write(Base58Util.decode(s.recoveryKey()));
out.write(Base58Util.decode(s.rootKey()));
u64le(out, s.createdAtMs());
u64le(out, updatedAt);
u32le(out, Integer.toUnsignedLong(s.recordNumber() + 1));
out.write(prevHash);
u64le(out, 0L); // additional_limit
out.write(Base58Util.decode(s.clientKey()));
out.write(Base58Util.decode(s.blockchainKey()));
strU8(out, s.blockchainName());
u64le(out, s.usedBytes());
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
out.write(unhex(s.lastBlockHash()));
out.write(Base58Util.decode(s.lastBlockSignature()));
strU8(out, s.arweaveTxId());
out.write(s.isServer() ? 1 : 0);
if (s.isServer()) {
out.write(s.addressFormatType() & 0xff);
out.write(s.addressFormatVersion() & 0xff);
strU8(out, s.serverAddress());
if (s.syncServers().size() > 255) throw new IOException("Слишком много sync_servers");
out.write(s.syncServers().size());
for (String v : s.syncServers()) strU8(out, v);
}
if (s.accessServers().size() > 255) throw new IOException("Слишком много access_servers");
out.write(s.accessServers().size());
for (String v : s.accessServers()) strU8(out, v);
out.write(s.sessionsMode() & 0xff);
if (s.sessions().size() > 255) throw new IOException("Слишком много sessions");
out.write(s.sessions().size());
for (ShineUsersCodec.UserSessionSnapshot session : s.sessions()) {
out.write(session.sessionType() & 0xff);
out.write(session.sessionVersion() & 0xff);
strU8(out, session.sessionName());
out.write(Base58Util.decode(session.sessionPubKey()));
}
out.write(s.trustedCount() & 0xff);
// Archive extension к обычному update_user_pda: marker=1 + tx32 + hash32.
out.write(1);
out.write(archiveTx);
out.write(archiveHash);
out.write(rootSig);
return out.toByteArray();
}
private static byte[] buildLastBlockState(UserPdaSnapshot s) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(LAST_BLOCK_STATE_PREFIX);
strU8(out, s.login());
strU8(out, s.blockchainName());
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
out.write(unhex(s.lastBlockHash()));
u64le(out, s.usedBytes());
return out.toByteArray();
}
private byte[] buildLegacyMessage(String userPda, byte[] updateData, byte[] rootEd, byte[] bchEd,
String recentBlockhash, byte[] clientPub) throws IOException {
String economy = SolanaPdaUtil.findProgramAddress(List.of(ECONOMY_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaUsersProgramId());
String inflow = SolanaPdaUtil.findProgramAddress(List.of(INFLOW_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaPaymentsProgramId());
List<Account> accounts = List.of(
new Account(Base58Util.encode(clientPub), true, true),
new Account(userPda, false, true),
new Account(inflow, false, true),
new Account(SYSTEM_PROGRAM, false, false),
new Account(SYSVAR_INSTRUCTIONS, false, false),
new Account(economy, false, false),
new Account(COMPUTE_BUDGET_PROGRAM, false, false),
new Account(ED25519_PROGRAM, false, false),
new Account(cfg.solanaUsersProgramId(), false, false)
);
Map<String,Integer> ix = new HashMap<>();
for (int i=0;i<accounts.size();i++) ix.put(accounts.get(i).pubkey, i);
List<CompiledInstruction> instructions = new ArrayList<>();
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeHeapData(262_144)));
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeLimitData(800_000)));
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], rootEd));
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], bchEd));
instructions.add(new CompiledInstruction(ix.get(cfg.solanaUsersProgramId()), new int[]{
ix.get(Base58Util.encode(clientPub)), ix.get(userPda), ix.get(SYSTEM_PROGRAM), ix.get(inflow),
ix.get(SYSVAR_INSTRUCTIONS), ix.get(economy)
}, updateData));
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(1); // numRequiredSignatures
out.write(0); // numReadonlySignedAccounts
out.write(6); // readonly unsigned: system, sysvar, economy, compute, ed25519, users
shortVec(out, accounts.size());
for (Account a : accounts) {
byte[] pk = Base58Util.decode(a.pubkey);
if (pk.length != 32) throw new IOException("Solana pubkey !=32: " + a.pubkey);
out.write(pk);
}
byte[] bh = Base58Util.decode(recentBlockhash);
if (bh.length != 32) throw new IOException("recentBlockhash !=32");
out.write(bh);
shortVec(out, instructions.size());
for (CompiledInstruction ci : instructions) {
out.write(ci.programIdIndex);
shortVec(out, ci.accounts.length);
for (int a : ci.accounts) out.write(a);
shortVec(out, ci.data.length);
out.write(ci.data);
}
return out.toByteArray();
}
private String getLatestBlockhash() throws Exception {
JsonNode result = rpc("getLatestBlockhash", List.of(Map.of("commitment", cfg.solanaCommitment()))).path("result");
String blockhash = result.path("value").path("blockhash").asText("");
if (blockhash.isBlank()) throw new IOException("Solana getLatestBlockhash не вернул blockhash");
return blockhash;
}
private String sendTransaction(byte[] tx) throws Exception {
String txB64 = Base64.getEncoder().encodeToString(tx);
JsonNode result = rpc("sendTransaction", List.of(txB64, Map.of(
"encoding", "base64",
"skipPreflight", false,
"preflightCommitment", "confirmed",
"maxRetries", 5
))).path("result");
String sig = result.asText("");
if (sig.isBlank()) throw new IOException("Solana sendTransaction не вернул signature");
return sig;
}
private JsonNode rpc(String method, List<?> params) throws Exception {
Map<String,Object> body = new LinkedHashMap<>();
body.put("jsonrpc", "2.0"); body.put("id", 1); body.put("method", method); body.put("params", params);
HttpRequest req = HttpRequest.newBuilder(URI.create(cfg.solanaRpcUrl()))
.timeout(Duration.ofSeconds(60))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body), StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Solana RPC HTTP " + resp.statusCode() + ": " + safe(resp.body()));
JsonNode root = JSON.readTree(resp.body());
if (root.hasNonNull("error")) throw new IOException("Solana RPC " + method + " error: " + root.path("error"));
return root;
}
private static byte[] serializeTransaction(byte[] signature64, byte[] message) throws IOException {
if (signature64.length != 64) throw new IOException("Solana signature !=64");
ByteArrayOutputStream out = new ByteArrayOutputStream();
shortVec(out, 1);
out.write(signature64);
out.write(message);
return out.toByteArray();
}
private static byte[] buildEd25519Instruction(byte[] signature64, byte[] publicKey32, byte[] message) {
if (signature64.length != 64 || publicKey32.length != 32) throw new IllegalArgumentException("Некорректный Ed25519 ix key/signature");
int sigOff=16, pkOff=sigOff+64, msgOff=pkOff+32;
if (message.length > 0xffff) throw new IllegalArgumentException("Ed25519 message слишком длинный");
byte[] data = new byte[msgOff + message.length];
data[0]=1; data[1]=0;
putU16le(data,2,sigOff); putU16le(data,4,0xffff);
putU16le(data,6,pkOff); putU16le(data,8,0xffff);
putU16le(data,10,msgOff); putU16le(data,12,message.length); putU16le(data,14,0xffff);
System.arraycopy(signature64,0,data,sigOff,64);
System.arraycopy(publicKey32,0,data,pkOff,32);
System.arraycopy(message,0,data,msgOff,message.length);
return data;
}
private static byte[] computeHeapData(int bytes) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(1); u32le(out, Integer.toUnsignedLong(bytes)); return out.toByteArray();
}
private static byte[] computeLimitData(int units) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(2); u32le(out, Integer.toUnsignedLong(units)); return out.toByteArray();
}
private static void shortVec(ByteArrayOutputStream out, int value) {
int rem=value;
while (true) {
int elem=rem & 0x7f; rem >>>=7;
if (rem==0) { out.write(elem); return; }
out.write(elem | 0x80);
}
}
private static void strU8(ByteArrayOutputStream out, String value) throws IOException {
byte[] b=String.valueOf(value==null?"":value).getBytes(StandardCharsets.UTF_8);
if (b.length>255) throw new IOException("Строка >255 bytes"); out.write(b.length); out.write(b);
}
private static void u32le(ByteArrayOutputStream out, long v) { for(int i=0;i<4;i++) out.write((int)(v >>> (8*i)) & 0xff); }
private static void u64le(ByteArrayOutputStream out, long v) { for(int i=0;i<8;i++) out.write((int)(v >>> (8*i)) & 0xff); }
private static void putU16le(byte[] d,int o,int v){d[o]=(byte)v;d[o+1]=(byte)(v>>>8);}
private static int u16le(byte[] b,int o){return (b[o]&255)|((b[o+1]&255)<<8);}
private static byte[] sha256(byte[] bytes) throws Exception { return MessageDigest.getInstance("SHA-256").digest(bytes); }
private static String hex(byte[] b){StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
private static byte[] unhex(String s){String x=String.valueOf(s==null?"":s).trim(); if(x.length()!=64) throw new IllegalArgumentException("hex32 expected");byte[]o=new byte[32];for(int i=0;i<32;i++)o[i]=(byte)Integer.parseInt(x.substring(i*2,i*2+2),16);return o;}
private static void require32(byte[] b,String n){if(b==null||b.length!=32)throw new IllegalArgumentException(n+" должен быть 32 bytes");}
private static String safe(String s){String x=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return x.length()<=500?x:x.substring(0,500)+"...";}
private record Current(String userPda, UserPdaSnapshot snapshot) {}
private record Account(String pubkey, boolean signer, boolean writable) {}
private record CompiledInstruction(int programIdIndex, int[] accounts, byte[] data) {}
}
@@ -0,0 +1,36 @@
package server.archive;
import blockchain.Ans104DataItem;
import org.junit.jupiter.api.Test;
import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
final class Ans104BundleTest {
@Test
void writeReadAndFindPreserveExactDataItems() {
byte[] a = item("a");
byte[] b = item("b");
byte[] bundle = Ans104Bundle.write(List.of(a, b));
List<Ans104Bundle.Entry> entries = Ans104Bundle.read(bundle, 10);
assertEquals(2, entries.size());
assertArrayEquals(a, entries.get(0).rawDataItem());
assertArrayEquals(b, entries.get(1).rawDataItem());
byte[] bId = new Ans104DataItem(b).id32();
assertArrayEquals(b, Ans104Bundle.find(bundle, bId, 10));
}
private static byte[] item(String payload) {
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("bundle-" + payload);
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
byte[] data = payload.getBytes(StandardCharsets.UTF_8);
List<Ans104DataItem.Tag> tags = List.of(new Ans104DataItem.Tag("App", "test5590"));
byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey);
return Ans104DataItem.serializeEd25519(signature, owner, tags, data);
}
}
@@ -1,34 +0,0 @@
package server.archive;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ArchiveFileNamesTest {
private static final long CREATED_AT = Instant.parse("2026-09-11T00:00:00Z").toEpochMilli();
private static final ZoneId UTC = ZoneId.of("UTC");
@Test
void usesTemporaryAndFinalArweaveNames() {
byte[] tx = new byte[32];
for (int i = 0; i < tx.length; i++) tx[i] = (byte) i;
String txText = Base64.getUrlEncoder().withoutPadding().encodeToString(tx);
assertEquals("archive01.00001.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive01", 1, CREATED_AT, UTC));
assertEquals("archive01.00001.11.09.26." + txText + ".SHiNE-archive",
ArchiveFileNames.uploadedFileName("archive01", 1, CREATED_AT, UTC, tx));
assertEquals("archive01.100000.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive01", 100000, CREATED_AT, UTC));
}
@Test
void sanitizesLoginForFileSystemOnly() {
assertEquals("archive_user.00002.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive/user", 2, CREATED_AT, UTC));
}
}
@@ -1,83 +0,0 @@
package server.archive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.entities.BlockEntry;
import java.io.DataInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ShineArchiveWriterTest {
@TempDir Path dir;
@Test
void writesOneChunkWithAllRecordsAndOneBacklink() throws Exception {
Path file = ArchiveFileNames.pendingFilePath(dir, "serverA", 2, 123456789L, ZoneId.of("UTC"));
ArchiveBigBlockRef previous = new ArchiveBigBlockRef(1, new byte[32], new byte[32]);
ArchivePublishJobChain chain = new ArchivePublishJobChain(
1, "alice-001", 10, 11, new byte[32], new byte[32],
1L, new byte[32], 100L, 55L, null, null);
ShineArchiveWriter.BuildResult result = new ShineArchiveWriter().build(
file, "serverA", 2, 123456789L,
List.of(previous), List.of(chain),
Map.of("alice-001", List.of(block(10, new byte[]{1,2,3}), block(11, new byte[]{4,5}))),
new byte[32], 4_000_000_000L);
assertArrayEquals(result.archiveHash(), ShineArchiveWriter.verifyLocalFile(file));
assertEquals("serverA.00002.02.01.70.tmp.SHiNE-archive", file.getFileName().toString());
try (InputStream raw = java.nio.file.Files.newInputStream(file); DataInputStream in = new DataInputStream(raw)) {
assertEquals("SHINE-ARCHIVE", new String(in.readNBytes(13), StandardCharsets.US_ASCII));
assertEquals(1, in.readUnsignedByte());
assertEquals(0, in.readUnsignedByte());
long headerSize = Integer.toUnsignedLong(in.readInt());
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertEquals(123456789L, in.readLong());
int creatorLen = in.readUnsignedByte();
assertEquals(ShineArchiveWriter.REFERENCES_FULL, in.readUnsignedByte());
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
assertEquals(68, in.readUnsignedShort());
assertEquals(0, Integer.toUnsignedLong(in.readInt()));
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertEquals("serverA", new String(in.readNBytes(creatorLen), StandardCharsets.UTF_8));
assertEquals(headerSize, 51L + creatorLen);
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
in.skipNBytes(64); // previous hash + txid
long chunkSize = Integer.toUnsignedLong(in.readInt());
int nameLen = in.readUnsignedByte();
assertEquals("alice-001", new String(in.readNBytes(nameLen), StandardCharsets.UTF_8));
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertEquals(3, Integer.toUnsignedLong(in.readInt()));
assertArrayEquals(new byte[]{1,2,3}, in.readNBytes(3));
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertArrayEquals(new byte[]{4,5}, in.readNBytes(2));
assertEquals(0, Integer.toUnsignedLong(in.readInt())); // previous ref index
assertEquals(100, Integer.toUnsignedLong(in.readInt()));
assertEquals(55, Integer.toUnsignedLong(in.readInt()));
assertEquals(4L + 1 + nameLen + 4 + (4+3) + (4+2) + 12, chunkSize);
}
}
private static BlockEntry block(int number, byte[] bytes) {
BlockEntry e = new BlockEntry();
e.setBlockNumber(number);
e.setBlockBytes(bytes);
e.setBlockHash(new byte[32]);
e.setBlockSignature(new byte[64]);
return e;
}
}
@@ -0,0 +1,363 @@
package blockchain;
import utils.crypto.Ed25519Util;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* Minimal ANS-104 DataItem parser/verifier used by SHiNE.
*
* SHiNE accepts only generic Ed25519 signature type 2:
* uint16le signatureType
* signature[64]
* owner[32]
* target_present[1] + target[32] when present
* anchor_present[1] + anchor[32] when present
* tags_count uint64le
* tags_size uint64le
* avro_tags[tags_size]
* data[*]
*
* The signing message matches the deployed arbundles/Turbo DataItem implementation:
* deepHash(["dataitem", "1", signatureType, owner, target, anchor, rawAvroTags, data]).
*/
public final class Ans104DataItem {
public static final int SIGNATURE_TYPE_ED25519 = 2;
public static final int SIGNATURE_LEN = 64;
public static final int OWNER_LEN = 32;
public static final int OPTIONAL_FIELD_LEN = 32;
public static final int ID_LEN = 32;
public static final int MAX_DATA_ITEM_BYTES = 8 * 1024 * 1024;
public static final int MAX_TAGS_BYTES = 64 * 1024;
public static final long MAX_TAGS_COUNT = 128;
public record Tag(String name, String value) {}
private final int signatureType;
private final byte[] signature;
private final byte[] owner;
private final byte[] target;
private final byte[] anchor;
private final long tagsCount;
private final byte[] rawTags;
private final List<Tag> tags;
private final byte[] data;
private final byte[] rawBytes;
private final byte[] id;
public Ans104DataItem(byte[] bytes) {
Objects.requireNonNull(bytes, "bytes == null");
if (bytes.length < 2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + 1) {
throw new IllegalArgumentException("ANS-104 DataItem too short: " + bytes.length);
}
if (bytes.length > MAX_DATA_ITEM_BYTES) {
throw new IllegalArgumentException("ANS-104 DataItem too large: " + bytes.length);
}
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
this.signatureType = Short.toUnsignedInt(bb.getShort());
if (signatureType != SIGNATURE_TYPE_ED25519) {
throw new IllegalArgumentException("Unsupported ANS-104 signature type: " + signatureType + " (expected 2)");
}
this.signature = readExact(bb, SIGNATURE_LEN, "signature");
this.owner = readExact(bb, OWNER_LEN, "owner");
this.target = readOptional32(bb, "target");
this.anchor = readOptional32(bb, "anchor");
this.tagsCount = readUnsignedLongAsNonNegative(bb, "tags_count");
long tagsSizeLong = readUnsignedLongAsNonNegative(bb, "tags_size");
if (tagsCount > MAX_TAGS_COUNT) {
throw new IllegalArgumentException("Too many ANS-104 tags: " + tagsCount);
}
if (tagsSizeLong > MAX_TAGS_BYTES || tagsSizeLong > bb.remaining()) {
throw new IllegalArgumentException("Bad ANS-104 tags_size: " + tagsSizeLong + ", remaining=" + bb.remaining());
}
this.rawTags = readExact(bb, (int) tagsSizeLong, "tags");
this.tags = Collections.unmodifiableList(parseAvroTags(rawTags, tagsCount));
this.data = new byte[bb.remaining()];
bb.get(this.data);
this.rawBytes = Arrays.copyOf(bytes, bytes.length);
this.id = sha256(signature);
}
public int signatureType() { return signatureType; }
public byte[] signature64() { return Arrays.copyOf(signature, signature.length); }
public byte[] owner32() { return Arrays.copyOf(owner, owner.length); }
public byte[] target() { return Arrays.copyOf(target, target.length); }
public byte[] anchor() { return Arrays.copyOf(anchor, anchor.length); }
public long tagsCount() { return tagsCount; }
public byte[] rawTags() { return Arrays.copyOf(rawTags, rawTags.length); }
public List<Tag> tags() { return tags; }
public byte[] data() { return Arrays.copyOf(data, data.length); }
public byte[] rawBytes() { return Arrays.copyOf(rawBytes, rawBytes.length); }
public byte[] id32() { return Arrays.copyOf(id, id.length); }
public String tagValue(String name) {
if (name == null) return null;
for (Tag tag : tags) {
if (name.equals(tag.name())) return tag.value();
}
return null;
}
public boolean hasTag(String name, String value) {
for (Tag tag : tags) {
if (Objects.equals(name, tag.name()) && Objects.equals(value, tag.value())) return true;
}
return false;
}
public boolean verifySignature() {
return Ed25519Util.verify(signingMessage(), signature, owner);
}
public boolean ownerEquals(byte[] expectedOwner32) {
return expectedOwner32 != null && expectedOwner32.length == OWNER_LEN && Arrays.equals(owner, expectedOwner32);
}
public byte[] signingMessage() {
return buildSigningMessage(owner, target, anchor, tags, data);
}
/**
* Builds the exact ANS-104 signing message for generic Ed25519 DataItems.
* Useful for SHiNE clients/tests that must sign a DataItem before serializing it.
*/
public static byte[] buildSigningMessage(byte[] owner32, List<Tag> tags, byte[] data) {
return buildSigningMessage(owner32, new byte[0], new byte[0], tags, data);
}
public static byte[] buildSigningMessage(byte[] owner32, byte[] target, byte[] anchor, List<Tag> tags, byte[] data) {
requireLen(owner32, OWNER_LEN, "owner");
byte[] safeTarget = target == null ? new byte[0] : Arrays.copyOf(target, target.length);
byte[] safeAnchor = anchor == null ? new byte[0] : Arrays.copyOf(anchor, anchor.length);
if (!(safeTarget.length == 0 || safeTarget.length == OPTIONAL_FIELD_LEN)) {
throw new IllegalArgumentException("target must be empty or 32 bytes");
}
if (!(safeAnchor.length == 0 || safeAnchor.length == OPTIONAL_FIELD_LEN)) {
throw new IllegalArgumentException("anchor must be empty or 32 bytes");
}
List<Tag> safeTags = validateTags(tags);
byte[] safeData = data == null ? new byte[0] : Arrays.copyOf(data, data.length);
byte[] rawTags = serializeAvroTags(safeTags);
return deepHash(List.of(
utf8("dataitem"),
utf8("1"),
utf8(Integer.toString(SIGNATURE_TYPE_ED25519)),
Arrays.copyOf(owner32, owner32.length),
safeTarget,
safeAnchor,
rawTags,
safeData
));
}
/** Serialize one SHiNE-ready ANS-104 DataItem with empty target/anchor. */
public static byte[] serializeEd25519(byte[] signature64, byte[] owner32, List<Tag> tags, byte[] data) {
requireLen(signature64, SIGNATURE_LEN, "signature");
requireLen(owner32, OWNER_LEN, "owner");
List<Tag> safeTags = validateTags(tags);
byte[] rawTags = serializeAvroTags(safeTags);
byte[] safeData = data == null ? new byte[0] : data;
ByteArrayOutputStream out = new ByteArrayOutputStream(
2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + rawTags.length + safeData.length);
writeU16LE(out, SIGNATURE_TYPE_ED25519);
out.writeBytes(signature64);
out.writeBytes(owner32);
out.write(0); // target absent
out.write(0); // anchor absent
writeU64LE(out, safeTags.size());
writeU64LE(out, rawTags.length);
out.writeBytes(rawTags);
out.writeBytes(safeData);
return out.toByteArray();
}
private static byte[] readOptional32(ByteBuffer bb, String name) {
if (!bb.hasRemaining()) throw new IllegalArgumentException("Missing " + name + " presence byte");
int present = Byte.toUnsignedInt(bb.get());
if (present == 0) return new byte[0];
if (present != 1) throw new IllegalArgumentException("Bad " + name + " presence byte: " + present);
return readExact(bb, OPTIONAL_FIELD_LEN, name);
}
private static byte[] readExact(ByteBuffer bb, int len, String field) {
if (len < 0 || bb.remaining() < len) {
throw new IllegalArgumentException("ANS-104 " + field + " truncated: need=" + len + " remaining=" + bb.remaining());
}
byte[] out = new byte[len];
bb.get(out);
return out;
}
private static long readUnsignedLongAsNonNegative(ByteBuffer bb, String field) {
if (bb.remaining() < 8) throw new IllegalArgumentException("Missing ANS-104 " + field);
long v = bb.getLong();
if (v < 0) throw new IllegalArgumentException("ANS-104 " + field + " exceeds signed long range");
return v;
}
private static List<Tag> validateTags(List<Tag> tags) {
List<Tag> safe = tags == null ? List.of() : List.copyOf(tags);
if (safe.size() > MAX_TAGS_COUNT) {
throw new IllegalArgumentException("Too many ANS-104 tags: " + safe.size());
}
for (Tag tag : safe) {
if (tag == null || tag.name() == null || tag.value() == null || tag.name().isEmpty() || tag.value().isEmpty()) {
throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
}
if (utf8(tag.name()).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
if (utf8(tag.value()).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
}
return safe;
}
private static byte[] serializeAvroTags(List<Tag> tags) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
if (!tags.isEmpty()) {
writeAvroLong(out, tags.size());
for (Tag tag : tags) {
writeAvroString(out, tag.name());
writeAvroString(out, tag.value());
}
}
writeAvroLong(out, 0); // array terminator; also the complete encoding for an empty array
byte[] raw = out.toByteArray();
if (raw.length > MAX_TAGS_BYTES) throw new IllegalArgumentException("ANS-104 tags exceed " + MAX_TAGS_BYTES + " bytes");
return raw;
}
private static void writeAvroString(ByteArrayOutputStream out, String value) {
byte[] bytes = utf8(value);
writeAvroLong(out, bytes.length);
out.writeBytes(bytes);
}
private static void writeAvroLong(ByteArrayOutputStream out, long value) {
long n = (value << 1) ^ (value >> 63);
while ((n & ~0x7fL) != 0) {
out.write((int) ((n & 0x7f) | 0x80));
n >>>= 7;
}
out.write((int) n);
}
private static void writeU16LE(ByteArrayOutputStream out, int value) {
out.write(value & 0xff);
out.write((value >>> 8) & 0xff);
}
private static void writeU64LE(ByteArrayOutputStream out, long value) {
for (int i = 0; i < 8; i++) out.write((int) ((value >>> (8 * i)) & 0xff));
}
private static void requireLen(byte[] bytes, int expected, String name) {
if (bytes == null || bytes.length != expected) {
throw new IllegalArgumentException(name + " must be " + expected + " bytes");
}
}
private static List<Tag> parseAvroTags(byte[] raw, long declaredCount) {
AvroCursor c = new AvroCursor(raw);
List<Tag> out = new ArrayList<>();
while (!c.eof()) {
long count = c.readLong();
if (count == 0) break;
if (count < 0) {
count = -count;
long blockBytes = c.readLong();
if (blockBytes < 0) throw new IllegalArgumentException("Negative Avro tag block size");
}
if (count > MAX_TAGS_COUNT || out.size() + count > MAX_TAGS_COUNT) {
throw new IllegalArgumentException("Too many Avro tags");
}
for (long i = 0; i < count; i++) {
String name = c.readString();
String value = c.readString();
if (name.isEmpty() || value.isEmpty()) throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
if (name.getBytes(StandardCharsets.UTF_8).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
if (value.getBytes(StandardCharsets.UTF_8).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
out.add(new Tag(name, value));
}
}
if (!c.eof()) {
throw new IllegalArgumentException("Trailing bytes in ANS-104 Avro tags");
}
if (out.size() != declaredCount) {
throw new IllegalArgumentException("ANS-104 tags_count mismatch: declared=" + declaredCount + " parsed=" + out.size());
}
return out;
}
private static final class AvroCursor {
private final byte[] data;
private int p;
private AvroCursor(byte[] data) { this.data = data == null ? new byte[0] : data; }
boolean eof() { return p == data.length; }
long readLong() {
long raw = 0;
int shift = 0;
while (true) {
if (p >= data.length) throw new IllegalArgumentException("Truncated Avro long");
int b = data[p++] & 0xff;
raw |= (long) (b & 0x7f) << shift;
if ((b & 0x80) == 0) break;
shift += 7;
if (shift > 63) throw new IllegalArgumentException("Avro long too large");
}
return (raw >>> 1) ^ -(raw & 1L);
}
String readString() {
long len = readLong();
if (len < 0 || len > Integer.MAX_VALUE || p + len > data.length) {
throw new IllegalArgumentException("Bad Avro string length: " + len);
}
String s = new String(data, p, (int) len, StandardCharsets.UTF_8);
p += (int) len;
return s;
}
}
private static byte[] deepHash(Object item) {
if (item instanceof byte[] bytes) {
return deepHashBlob(bytes);
}
if (item instanceof List<?> items) {
byte[] acc = sha384(utf8("list" + items.size()));
for (Object child : items) {
acc = sha384(concat(acc, deepHash(child)));
}
return acc;
}
throw new IllegalArgumentException("Unsupported deepHash item: " + (item == null ? "null" : item.getClass()));
}
private static byte[] deepHashBlob(byte[] bytes) {
byte[] tag = utf8("blob" + bytes.length);
return sha384(concat(sha384(tag), sha384(bytes)));
}
private static byte[] utf8(String s) { return s.getBytes(StandardCharsets.UTF_8); }
private static byte[] sha256(byte[] b) { return digest("SHA-256", b); }
private static byte[] sha384(byte[] b) { return digest("SHA-384", b); }
private static byte[] digest(String alg, byte[] b) {
try { return MessageDigest.getInstance(alg).digest(b); }
catch (Exception e) { throw new IllegalStateException(alg + " unavailable", e); }
}
private static byte[] concat(byte[] a, byte[] b) {
ByteArrayOutputStream out = new ByteArrayOutputStream(a.length + b.length);
out.writeBytes(a); out.writeBytes(b); return out.toByteArray();
}
}
@@ -9,364 +9,116 @@ import java.util.Arrays;
import java.util.Objects;
/**
* BchBlockEntry — универсальный блок формата SHiNE (Frame v0).
* SHiNE user block, Frame v1, carried as the data payload of an ANS-104 DataItem.
*
* =========================================================================
* FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА)
* =========================================================================
* The constructor accepts the complete serialized ANS-104 DataItem because this is
* exactly what is stored in PostgreSQL and exchanged between servers.
*
* Все числа BigEndian.
* SHiNE Frame v1 (all integer fields BigEndian):
* [2] frameCode = 0x0001
* [32] prevHash32 = SHA-256(Frame v1 bytes of previous SHiNE block)
* [4] blockSize = exact Frame v1 byte length
* [4] blockNumber
* [8] timestamp unix seconds
* [2] type
* [2] subType
* [2] version
* [N] bodyBytes
*
* PREIMAGE (входит в blockSize, подписывается):
* [2] frameCode (uint16) код/версия рамки:
* - 0x0000 = Frame v0 (текущий)
* [32] prevHash32 (bytes) SHA-256(preimage) предыдущего блока (цепочка)
* [4] blockSize (int32) размер preimage (в байтах), ВКЛЮЧАЯ frameCode,
* НО БЕЗ sigMarker и БЕЗ signature64
* [4] blockNumber (int32) глобальный номер блока (>=0)
* [8] timestamp (int64) unix seconds
* [2] type (uint16) тип сообщения
* [2] subType (uint16) подтип сообщения
* [2] version (uint16) версия формата сообщения
* [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version)
*
* TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0):
* [2] sigMarker (uint16) маркер подписи:
* - 0x0100 (256) = далее подпись Ed25519 64 байта
* [64] signature64 (bytes) Ed25519 signature над hash32
*
* hash32 НЕ хранится в блоке.
* hash32 вычисляется при парсинге:
* preimage = первые blockSize байт
* hash32 = SHA-256(preimage)
*
* Правила MVP-парсера (Frame v0):
* - frameCode должен быть строго 0x0000, иначе REJECT.
* - sigMarker должен быть строго 0x0100, иначе REJECT.
* - подпись обязана присутствовать всегда (sigMarker+signature64).
* - НИКАКИХ fallback-веток “если маркер другой, то подписи нет/другой хвост”.
*
* Важно по безопасности:
* - sigMarker в v0 не входит в подписываемые байты → его можно подменить,
* поэтому единственная безопасная логика: "если не 0x0100 — reject".
* =========================================================================
* The user signature is NOT stored in this frame. It is the single Ed25519/Solana
* signature of the surrounding ANS-104 DataItem.
*/
public final class BchBlockEntry {
public static final int SIGNATURE_LEN = 64;
public static final int HASH_LEN = 32;
public static final int FRAME_CODE_LEN = 2;
public static final int SIG_MARKER_LEN = 2;
/** Frame v0 */
public static final int FRAME_CODE_V0 = 0x0000;
/** sigMarker: 256 = 0x0100 */
public static final int SIG_MARKER_ED25519 = 0x0100;
/**
* Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature),
* чтобы не уложить сервер по памяти/диску.
*/
public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024;
/**
* Насколько блок может “обгонять” текущее время (защита от кривых часов/вбросов).
* Если timestamp больше now + 60 сек — блок считаем неверным.
*/
public static final int FRAME_CODE_V1 = 0x0001;
public static final int MAX_BLOCK_FULL_BYTES = Ans104DataItem.MAX_DATA_ITEM_BYTES;
public static final long MAX_FUTURE_SECONDS = 60;
public static final int FRAME_HEADER_SIZE = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2;
/**
* Размер фиксированной части PREIMAGE (без bodyBytes).
*
* PREIMAGE header:
* frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8)
* + type(2) + subType(2) + version(2)
*/
public static final int PREIMAGE_HEADER_SIZE =
2 // frameCode
+ 32 // prevHash32
+ 4 // blockSize
+ 4 // blockNumber
+ 8 // timestamp
+ 2 // type
+ 2 // subType
+ 2; // version
/** Минимальный полный размер блока (без bodyBytes). */
public static final int MIN_FULL_BYTES =
PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN;
// --- HEADER (PREIMAGE) ---
public final int frameCode; // uint16 (v0=0)
public final byte[] prevHash32; // 32
public final int blockSize; // preimage size (включая frameCode)
public final int blockNumber; // >=0
public final int frameCode;
public final byte[] prevHash32;
public final int blockSize;
public final int blockNumber;
public final long timestamp;
public final short type;
public final short subType;
public final short version;
// --- BODY (PREIMAGE) ---
public final byte[] bodyBytes;
/** Распарсенное тело (создаётся сразу при парсинге блока). */
public final BodyRecord body;
// --- TAIL ---
public final int sigMarker; // uint16 (v0: 0x0100)
private final byte[] signature64; // 64
private final Ans104DataItem dataItem;
private final byte[] frameBytes;
private final byte[] hash32;
// --- derived ---
private final byte[] hash32; // 32, computed
private final byte[] preimage; // blockSize bytes
private final byte[] fullBytes; // preimage + sigMarker + signature
/* ===================================================================== */
/* ====================== Конструктор из байт ========================== */
/* ===================================================================== */
public BchBlockEntry(byte[] fullBytes) {
Objects.requireNonNull(fullBytes, "fullBytes == null");
if (fullBytes.length < MIN_FULL_BYTES) {
throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES);
}
if (fullBytes.length > MAX_BLOCK_FULL_BYTES) {
throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES);
/** Parse a complete serialized ANS-104 DataItem containing one SHiNE Frame v1. */
public BchBlockEntry(byte[] fullDataItemBytes) {
Objects.requireNonNull(fullDataItemBytes, "fullDataItemBytes == null");
this.dataItem = new Ans104DataItem(fullDataItemBytes);
this.frameBytes = dataItem.data();
if (frameBytes.length < FRAME_HEADER_SIZE) {
throw new IllegalArgumentException("SHiNE Frame v1 too short: " + frameBytes.length);
}
ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN);
// [2] frameCode
ByteBuffer bb = ByteBuffer.wrap(frameBytes).order(ByteOrder.BIG_ENDIAN);
this.frameCode = Short.toUnsignedInt(bb.getShort());
if (this.frameCode != FRAME_CODE_V0) {
throw new IllegalArgumentException(String.format(
"Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0
));
if (frameCode != FRAME_CODE_V1) {
throw new IllegalArgumentException(String.format("Bad SHiNE frameCode: 0x%04X (expected 0x%04X)", frameCode, FRAME_CODE_V1));
}
// [32] prevHash32
this.prevHash32 = new byte[32];
bb.get(this.prevHash32);
// [4] blockSize
bb.get(prevHash32);
this.blockSize = bb.getInt();
if (blockSize < PREIMAGE_HEADER_SIZE) {
throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE);
if (blockSize != frameBytes.length || blockSize < FRAME_HEADER_SIZE) {
throw new IllegalArgumentException("SHiNE blockSize mismatch: declared=" + blockSize + " actual=" + frameBytes.length);
}
// fullLen must match exactly: blockSize + sigMarker(2) + signature(64)
int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
if (expectedFullLen != fullBytes.length) {
throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize
+ " expectedFullLen=" + expectedFullLen
+ " fullLen=" + fullBytes.length);
}
if (expectedFullLen > MAX_BLOCK_FULL_BYTES) {
throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES);
}
// [4] blockNumber
this.blockNumber = bb.getInt();
if (this.blockNumber < 0) {
throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber);
}
// [8] timestamp
if (blockNumber < 0) throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
this.timestamp = bb.getLong();
// запрет “в будущее” больше чем на 1 минуту
long now = Instant.now().getEpochSecond();
if (this.timestamp > now + MAX_FUTURE_SECONDS) {
throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
if (timestamp > now + MAX_FUTURE_SECONDS) {
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp + " now=" + now);
}
// [2][2][2] type/subType/version
this.type = bb.getShort();
this.subType = bb.getShort();
this.version = bb.getShort();
// [N] bodyBytes
int bodyLen = blockSize - PREIMAGE_HEADER_SIZE;
if (bodyLen < 0) {
throw new IllegalArgumentException("Invalid body length: " + bodyLen);
}
this.bodyBytes = new byte[bodyLen];
bb.get(this.bodyBytes);
// TAIL: [2] sigMarker
this.sigMarker = Short.toUnsignedInt(bb.getShort());
if (this.sigMarker != SIG_MARKER_ED25519) {
throw new IllegalArgumentException(String.format(
"Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519
));
}
// TAIL: [64] signature64
this.signature64 = new byte[SIGNATURE_LEN];
bb.get(this.signature64);
// preimage = первые blockSize байт (включая frameCode)
this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize);
// hash32 = sha256(preimage)
this.hash32 = BchCryptoVerifier.sha256(preimage);
// parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check()
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length);
if (bb.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
this.bodyBytes = new byte[bb.remaining()];
bb.get(bodyBytes);
this.body = BodyRecordParser.parse(type, subType, version, bodyBytes);
this.hash32 = BchCryptoVerifier.sha256(frameBytes);
}
/* ===================================================================== */
/* ====================== Конструктор сборки ============================ */
/* ===================================================================== */
public BchBlockEntry(byte[] prevHash32,
int blockNumber,
long timestamp,
short type,
short subType,
short version,
byte[] bodyBytes,
byte[] signature64) {
Objects.requireNonNull(prevHash32, "prevHash32 == null");
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
Objects.requireNonNull(signature64, "signature64 == null");
if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32");
if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64");
if (blockNumber < 0) {
throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
}
// запрет “в будущее” больше чем на 1 минуту
long now = Instant.now().getEpochSecond();
if (timestamp > now + MAX_FUTURE_SECONDS) {
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
}
this.frameCode = FRAME_CODE_V0;
this.prevHash32 = Arrays.copyOf(prevHash32, 32);
this.blockNumber = blockNumber;
this.timestamp = timestamp;
this.type = type;
this.subType = subType;
this.version = version;
this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length);
// blockSize = размер preimage (включая frameCode)
this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length;
int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
if (fullLen > MAX_BLOCK_FULL_BYTES) {
throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES);
}
// parse body по header + ОБЯЗАТЕЛЬНЫЙ check()
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
// tail marker фиксирован
this.sigMarker = SIG_MARKER_ED25519;
this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN);
// build preimage
ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN);
pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF));
pre.put(this.prevHash32);
pre.putInt(this.blockSize);
pre.putInt(this.blockNumber);
pre.putLong(this.timestamp);
pre.putShort(this.type);
pre.putShort(this.subType);
pre.putShort(this.version);
pre.put(this.bodyBytes);
this.preimage = pre.array();
this.hash32 = BchCryptoVerifier.sha256(preimage);
// build fullBytes: preimage + sigMarker + signature64
ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN);
full.put(this.preimage);
full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF));
full.put(this.signature64);
this.fullBytes = full.array();
}
/* ===================================================================== */
/* ============================ Getters ================================= */
/* ===================================================================== */
public byte[] getPreimageBytes() {
return Arrays.copyOf(preimage, preimage.length);
}
/** Возвращает подпись Ed25519 (64 байта). */
public byte[] getSignature64() {
return Arrays.copyOf(signature64, SIGNATURE_LEN);
}
/** Возвращает hash32 = SHA-256(preimage). */
public byte[] getHash32() {
return Arrays.copyOf(hash32, HASH_LEN);
}
/** Возвращает полный блок: preimage + sigMarker + signature. */
public byte[] toBytes() {
return Arrays.copyOf(fullBytes, fullBytes.length);
}
public Ans104DataItem getDataItem() { return dataItem; }
public byte[] getFrameBytes() { return Arrays.copyOf(frameBytes, frameBytes.length); }
/** Kept as an alias for code/docs that called the old field preimage. */
public byte[] getPreimageBytes() { return getFrameBytes(); }
public byte[] getSignature64() { return dataItem.signature64(); }
public byte[] getOwner32() { return dataItem.owner32(); }
public byte[] getDataItemId32() { return dataItem.id32(); }
public byte[] getHash32() { return Arrays.copyOf(hash32, hash32.length); }
/** Full serialized ANS-104 DataItem. */
public byte[] toBytes() { return dataItem.rawBytes(); }
@Override
public String toString() {
String timeIso;
try {
timeIso = Instant.ofEpochSecond(timestamp).toString();
} catch (Exception e) {
timeIso = "некорректныйTimestamp";
}
return "BchBlockEntry{"
+ "FRAME{frameCode=0x" + hex4(frameCode)
+ "}, HDR{"
+ "blockSize=" + blockSize
+ ", blockNumber=" + blockNumber
+ ", timestamp=" + timestamp + " (" + timeIso + ")"
+ ", type=" + (type & 0xFFFF)
+ ", subType=" + (subType & 0xFFFF)
+ ", version=" + (version & 0xFFFF)
+ ", prevHash32(hex)=" + toHex(prevHash32)
+ "}"
+ ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}"
+ ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}"
+ ", DERIVED{hash32(hex)=" + toHex(hash32) + "}"
+ "}";
}
private static String hex4(int v) {
String s = Integer.toHexString(v & 0xFFFF);
while (s.length() < 4) s = "0" + s;
return s;
return "BchBlockEntry{" +
"frameCode=0x" + Integer.toHexString(frameCode) +
", blockNumber=" + blockNumber +
", timestamp=" + timestamp +
", type=" + (type & 0xffff) +
", subType=" + (subType & 0xffff) +
", version=" + (version & 0xffff) +
", bodyLen=" + bodyBytes.length +
", dataItemId=" + toHex(getDataItemId32()) +
", blockHash=" + toHex(hash32) +
'}';
}
private static String toHex(byte[] bytes) {
if (bytes == null) return "null";
char[] HEX = "0123456789abcdef".toCharArray();
char[] h = "0123456789abcdef".toCharArray();
char[] out = new char[bytes.length * 2];
for (int i = 0; i < bytes.length; i++) {
int vv = bytes[i] & 0xFF;
out[i * 2] = HEX[vv >>> 4];
out[i * 2 + 1] = HEX[vv & 0x0F];
int v = bytes[i] & 0xff;
out[i * 2] = h[v >>> 4]; out[i * 2 + 1] = h[v & 15];
}
return new String(out);
}
}
}
@@ -1,42 +1,27 @@
package blockchain;
import utils.crypto.Ed25519Util;
import java.security.MessageDigest;
import java.util.Objects;
/**
* Верификатор SHiNE (Frame v0):
*
* preimage = первые blockSize байт блока (ВКЛЮЧАЯ frameCode=0x0000),
* = всё до TAIL (sigMarker+signature).
*
* hash32 = SHA-256(preimage)
* verify = Ed25519.verify(hash32, signature64, pubKey32)
*/
/** Cryptographic checks for SHiNE Frame v1 + surrounding ANS-104 DataItem. */
public final class BchCryptoVerifier {
private BchCryptoVerifier() {}
public static byte[] sha256(byte[] data) {
Objects.requireNonNull(data, "data == null");
try {
MessageDigest d = MessageDigest.getInstance("SHA-256");
return d.digest(data);
} catch (Exception e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
try { return MessageDigest.getInstance("SHA-256").digest(data); }
catch (Exception e) { throw new IllegalStateException("SHA-256 unavailable", e); }
}
/**
* Verifies that the ANS-104 owner is the current blockchain key and that the
* ANS-104 Ed25519 signature is valid. The SHiNE block hash itself remains
* SHA-256(Frame v1 bytes) and is intentionally independent of Arweave.
*/
public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) {
Objects.requireNonNull(block, "block == null");
Objects.requireNonNull(publicKey32, "publicKey32 == null");
if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32");
byte[] hash32 = block.getHash32();
byte[] sig64 = block.getSignature64();
return Ed25519Util.verify(hash32, sig64, publicKey32);
return block.getDataItem().ownerEquals(publicKey32) && block.getDataItem().verifySignature();
}
}
}
@@ -1,297 +0,0 @@
package utils.files;
import java.io.IOException;
import java.nio.file.*;
import java.util.Objects;
/**
* ===============================================================
* FileStoreUtil — утилита работы с файлами в папке data/.
*
* Теперь поддерживает:
* - основной файл блокчейна: <blockchainName>.bch
* - временный файл блокчейна: <blockchainName>.tmp_bch
* - sidecar-файл проверки записи: <blockchainName>.write_check
* - marker-файл записи: <blockchainName>.write_pending
*
* Важное:
* - validateSimpleFileName() запрещает path traversal.
* - atomicReplaceBlockchainFile(): пытается сделать ATOMIC_MOVE (если ФС поддерживает),
* иначе делает обычный REPLACE_EXISTING move.
* ===============================================================
*/
public final class FileStoreUtil {
/** Базовая папка для хранения всех файлов (создаётся автоматически). */
public static final String DATA_DIR_NAME = "data";
/** Расширение основного файла блокчейна. */
public static final String BLOCKCHAIN_FILE_EXTENSION = ".bch";
/** Расширение временного файла (старое+новое). */
public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch";
/** Маркер того, что chain сейчас в процессе полного resync. */
public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending";
/** Marker того, что обычный AddBlock находится в опасной фазе записи. */
public static final String BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION = ".write_pending";
/** Sidecar-файл с blockNumber/blockHash для обычного AddBlock. */
public static final String BLOCKCHAIN_WRITE_CHECK_EXTENSION = ".write_check";
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
private final Path dataDirPath;
private FileStoreUtil() {
this.dataDirPath = Paths.get(DATA_DIR_NAME);
ensureDataDirExists();
}
public static FileStoreUtil getInstance() {
return INSTANCE;
}
/* ===================================================================== */
/* ======================== Базовые операции =========================== */
/* ===================================================================== */
public void newFile(String fileName, byte[] data) {
Objects.requireNonNull(data, "data == null");
Path target = resolveSafe(fileName);
try {
Files.write(target, data,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
} catch (IOException e) {
throw new IllegalStateException("Не удалось записать файл: " + target, e);
}
}
public void addDataToFile(String fileName, byte[] data) {
Objects.requireNonNull(data, "data == null");
Path target = resolveSafe(fileName);
try {
Files.write(target, data,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.APPEND);
} catch (IOException e) {
throw new IllegalStateException("Не удалось дописать файл: " + target, e);
}
}
public byte[] readAllDataFromFile(String fileName) {
Path target = resolveSafe(fileName);
if (!Files.exists(target)) {
throw new IllegalStateException("Файл не найден: " + target);
}
try {
return Files.readAllBytes(target);
} catch (IOException e) {
throw new IllegalStateException("Не удалось прочитать файл: " + target, e);
}
}
public boolean exists(String fileName) {
Path target = resolveSafe(fileName);
return Files.exists(target);
}
public long size(String fileName) {
Path target = resolveSafe(fileName);
try {
return Files.size(target);
} catch (IOException e) {
throw new IllegalStateException("Не удалось получить размер файла: " + target, e);
}
}
/* ===================================================================== */
/* ===================== Блокчейн-файлы по имени ======================= */
/* ===================================================================== */
/** <blockchainName>.bch */
public String buildBlockchainFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_FILE_EXTENSION;
}
/** <blockchainName>.tmp_bch */
public String buildBlockchainTmpFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_TMP_EXTENSION;
}
public Path resolveBlockchainPath(String blockchainName) {
return resolveSafe(buildBlockchainFileName(blockchainName));
}
public Path resolveBlockchainTmpPath(String blockchainName) {
return resolveSafe(buildBlockchainTmpFileName(blockchainName));
}
public byte[] readBlockchain(String blockchainName) {
return readAllDataFromFile(buildBlockchainFileName(blockchainName));
}
public void writeBlockchainTmp(String blockchainName, byte[] data) {
newFile(buildBlockchainTmpFileName(blockchainName), data);
}
/** <blockchainName>.write_check */
public String buildBlockchainWriteCheckFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_WRITE_CHECK_EXTENSION;
}
public Path resolveBlockchainWriteCheckPath(String blockchainName) {
return resolveSafe(buildBlockchainWriteCheckFileName(blockchainName));
}
public void writeBlockchainWriteCheck(String blockchainName, int blockNumber, String blockHashHex) {
StringBuilder sb = new StringBuilder(128);
sb.append("blockNumber=").append(blockNumber).append('\n');
sb.append("blockHash=").append(blockHashHex == null ? "" : blockHashHex).append('\n');
newFile(buildBlockchainWriteCheckFileName(blockchainName), sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
/** <blockchainName>.write_pending */
public String buildBlockchainWritePendingMarkerFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION;
}
public Path resolveBlockchainWritePendingMarkerPath(String blockchainName) {
return resolveSafe(buildBlockchainWritePendingMarkerFileName(blockchainName));
}
public void writeBlockchainWritePendingMarker(String blockchainName) {
newFile(buildBlockchainWritePendingMarkerFileName(blockchainName), new byte[0]);
}
/** <blockchainName>.resync_pending */
public String buildBlockchainResyncMarkerFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_RESYNC_MARKER_EXTENSION;
}
public Path resolveBlockchainResyncMarkerPath(String blockchainName) {
return resolveSafe(buildBlockchainResyncMarkerFileName(blockchainName));
}
public void writeBlockchainResyncMarker(String blockchainName, String markerContent) {
byte[] data = markerContent == null ? new byte[0] : markerContent.getBytes(java.nio.charset.StandardCharsets.UTF_8);
newFile(buildBlockchainResyncMarkerFileName(blockchainName), data);
}
public void deleteIfExists(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException e) {
throw new IllegalStateException("Не удалось удалить файл: " + path, e);
}
}
public void deleteBlockchainFileIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainPath(blockchainName));
}
public void deleteBlockchainTmpFileIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainTmpPath(blockchainName));
}
public void deleteBlockchainWriteCheckIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName));
}
public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName));
}
public void deleteBlockchainResyncMarkerIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName));
}
public boolean existsBlockchainResyncMarker(String blockchainName) {
return exists(buildBlockchainResyncMarkerFileName(blockchainName));
}
/**
* Атомарно заменить основной файл блокчейна временным:
* <name>.tmp_bch -> <name>.bch
*
* Стратегия:
* 1) Пытаемся Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING)
* 2) Если ATOMIC_MOVE не поддерживается — делаем move с REPLACE_EXISTING без атомарности
*
* Важный нюанс:
* - атомарность гарантируется только в пределах одной файловой системы.
*/
public void atomicReplaceBlockchainFile(String blockchainName) {
Path tmp = resolveBlockchainTmpPath(blockchainName);
Path main = resolveBlockchainPath(blockchainName);
if (!Files.exists(tmp)) {
throw new IllegalStateException("TMP-файл не найден: " + tmp);
}
try {
// 1) Пытаемся атомарный move
Files.move(tmp, main,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
// 2) Если ФС не поддерживает атомарный move — делаем обычный replace
try {
Files.move(tmp, main, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ex) {
throw new IllegalStateException("Не удалось заменить файл блокчейна (non-atomic): " + main, ex);
}
} catch (IOException e) {
throw new IllegalStateException("Не удалось заменить файл блокчейна (atomic): " + main, e);
}
}
/* ===================================================================== */
/* ============================ Helpers ================================= */
/* ===================================================================== */
private void ensureDataDirExists() {
try {
if (!Files.exists(dataDirPath)) {
Files.createDirectories(dataDirPath);
}
} catch (IOException e) {
throw new IllegalStateException("Не удалось создать директорию хранения: " + dataDirPath, e);
}
}
private Path resolveSafe(String fileName) {
validateSimpleFileName(fileName);
return dataDirPath.resolve(fileName);
}
/**
* Валидация "простого имени":
* - запрещаем слэши, обратные слэши, ".."
* - запрещаем пустоту
*
* Важно: сюда у нас попадает и blockchainName (как часть имени файла),
* поэтому blockchainName должен быть "простым": без путей.
*/
private void validateSimpleFileName(String fileName) {
Objects.requireNonNull(fileName, "fileName == null");
if (fileName.isBlank()) {
throw new IllegalArgumentException("Имя файла не должно быть пустым");
}
if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
throw new IllegalArgumentException("Недопустимое имя файла: " + fileName);
}
}
}
@@ -0,0 +1,74 @@
package blockchain;
import org.junit.jupiter.api.Test;
import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
final class Ans104DataItemTest {
@Test
void ed25519RoundTripWithTestAndChannelTags() {
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-test-key");
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
List<Ans104DataItem.Tag> tags = List.of(
new Ans104DataItem.Tag("App", "test5590"),
new Ans104DataItem.Tag("c", "books")
);
byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data);
byte[] signature = Ed25519Util.sign(message, privateKey);
byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data);
Ans104DataItem parsed = new Ans104DataItem(raw);
assertEquals(2, parsed.signatureType());
assertArrayEquals(owner, parsed.owner32());
assertArrayEquals(data, parsed.data());
assertTrue(parsed.hasTag("App", "test5590"));
assertEquals("books", parsed.tagValue("c"));
assertTrue(parsed.verifySignature());
assertEquals(32, parsed.id32().length);
}
@Test
void mutationBreaksSignature() {
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-mutation-key");
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
byte[] data = "original".getBytes(StandardCharsets.UTF_8);
List<Ans104DataItem.Tag> tags = List.of(new Ans104DataItem.Tag("App", "test5590"));
byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey);
byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data);
raw[raw.length - 1] ^= 1;
assertFalse(new Ans104DataItem(raw).verifySignature());
}
@Test
void signingMessageMatchesArbundlesTurboCompatibilityVector() {
byte[] owner = new byte[32];
for (int i = 0; i < owner.length; i++) owner[i] = (byte) i;
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
List<Ans104DataItem.Tag> tags = List.of(
new Ans104DataItem.Tag("App", "test5590"),
new Ans104DataItem.Tag("c", "books")
);
byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data);
byte[] expected = hex("7e67d0debce103606d697a1e3785130ca20cb89cd8a06f9a65b98b2d2427eaf411fcf7d482da268e44f3ac25c57c3cb9");
assertArrayEquals(expected, actual);
}
private static byte[] hex(String value) {
if ((value.length() & 1) != 0) throw new IllegalArgumentException("odd hex length");
byte[] out = new byte[value.length() / 2];
for (int i = 0; i < out.length; i++) {
int hi = Character.digit(value.charAt(i * 2), 16);
int lo = Character.digit(value.charAt(i * 2 + 1), 16);
if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex");
out[i] = (byte) ((hi << 4) | lo);
}
return out;
}
}
@@ -1,7 +1,7 @@
# utils.crypto
Пакет отвечает за криптографию — подписи и хэши блоков.
Используется при создании и проверке целостности `.bch`-блоков.
Используется при создании и проверке криптографических сущностей SHiNE; пользовательские blockchain-файлы `.bch` больше не используются.
---
@@ -40,6 +40,7 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_21 = 21;
public static final int SCHEMA_VERSION_22 = 22;
public static final int SCHEMA_VERSION_23 = 23;
public static final int SCHEMA_VERSION_24 = 24;
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
@@ -63,6 +64,7 @@ public final class DatabaseInitializer {
public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql";
public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql";
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
private DatabaseInitializer() {}
@@ -224,6 +226,10 @@ public final class DatabaseInitializer {
runSqlScript(conn, POSTGRES_MIGRATION_V23_RESOURCE);
currentVersion = SCHEMA_VERSION_23;
}
if (currentVersion < SCHEMA_VERSION_24) {
runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE);
currentVersion = SCHEMA_VERSION_24;
}
}
}
@@ -1,8 +0,0 @@
package shine.db.archive;
/** Ссылка на уже успешно опубликованный большой архивный блок. */
public record ArchiveBigBlockRef(
long bigBlockNumber,
byte[] archiveHash,
byte[] arweaveTxId
) {}
@@ -1,14 +0,0 @@
package shine.db.archive;
/** Последний известный архивный chunk конкретной blockchain на этом сервере. */
public record ArchiveBlockchainLocation(
String blockchainName,
String publisherLogin,
String arweaveTxId,
byte[] archiveHash,
long bigBlockNumber,
long chunkOffset,
long chunkSize,
long sourceLastBlockNumber,
long updatedAtMs
) {}
@@ -1,15 +0,0 @@
package shine.db.archive;
/**
* Последнее окончательно опубликованное архивное положение одной SHiNE blockchain.
*/
public record ArchiveChainCursor(
String blockchainName,
long lastArchivedSourceBlockNumber,
byte[] lastArchivedSourceBlockHash,
long lastArchiveBigBlockNumber,
byte[] lastArchiveBigBlockHash,
long lastChunkOffset,
long lastChunkSize,
long updatedAtMs
) {}
@@ -1,16 +0,0 @@
package shine.db.archive;
/** Crash-safe состояние публикации одного большого SHINE-ARCHIVE блока. */
public record ArchivePublishJob(
long id,
long bigBlockNumber,
String status,
long createdAtMs,
String localArchivePath,
byte[] archiveHash,
byte[] arweaveTxId,
Integer arweaveConfirmations,
String solanaSignature,
String errorText,
long updatedAtMs
) {}
@@ -1,17 +0,0 @@
package shine.db.archive;
/** Замороженный диапазон одной blockchain внутри конкретной архивной публикации. */
public record ArchivePublishJobChain(
long jobId,
String blockchainName,
long fromSourceBlockNumber,
long toSourceBlockNumber,
byte[] previousSourceBlockHash,
byte[] lastSourceBlockHash,
Long previousArchiveBigBlockNumber,
byte[] previousArchiveBigBlockHash,
Long previousChunkOffset,
Long previousChunkSize,
Long newChunkOffset,
Long newChunkSize
) {}
@@ -1,14 +0,0 @@
package shine.db.archive;
/**
* Текущая archive head trusted publisher-а из локальной таблицы,
* которую уже заполнил обычный Solana users sync.
*/
public record ArchivePublisherHead(
String login,
String rootKey,
String archiveHeadTxId,
String archiveHeadHash,
boolean archiveImported,
String lastImportedArchiveTxId
) {}
@@ -1,150 +0,0 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.archive.ArchiveBlockchainLocation;
import shine.db.archive.ArchivePublisherHead;
import java.sql.*;
import java.util.*;
/** БД индекс последних archive chunks и локальное состояние импорта archive head. */
public final class ArchiveImportDAO {
private static volatile ArchiveImportDAO instance;
private final DbController db = DbController.getInstance();
private ArchiveImportDAO() {}
public static ArchiveImportDAO getInstance() {
if (instance == null) synchronized (ArchiveImportDAO.class) {
if (instance == null) instance = new ArchiveImportDAO();
}
return instance;
}
/**
* Возвращает только доверенных publisher-ов, для которых обычный Solana users sync
* уже увидел archive head, но этот head ещё не был полностью импортирован локально.
*/
public List<ArchivePublisherHead> listPendingPublisherHeads(Set<String> approvedLogins) throws SQLException {
if (approvedLogins == null || approvedLogins.isEmpty()) return List.of();
List<ArchivePublisherHead> out = new ArrayList<>();
String sql = """
SELECT login, root_key, archive_head_tx_id, archive_head_hash,
archive_imported, archive_last_imported_tx_id
FROM solana_user_pda_current
WHERE LOWER(login)=LOWER(?)
AND is_server=TRUE
AND archive_head_tx_id<>''
AND archive_head_hash<>''
AND archive_imported=FALSE
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
for (String login : approvedLogins) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
out.add(new ArchivePublisherHead(
rs.getString("login"),
rs.getString("root_key"),
rs.getString("archive_head_tx_id"),
rs.getString("archive_head_hash"),
rs.getBoolean("archive_imported"),
rs.getString("archive_last_imported_tx_id")));
}
}
}
}
return out;
}
/**
* Сдвигает локальный курсор импорта внутри FULL archive chain.
* Update выполняется только если текущий head всё ещё тот же, с которым работает importer.
*/
public boolean advanceImportCursor(String publisherLogin, String expectedHeadTxId, String importedTxId) throws SQLException {
String sql = """
UPDATE solana_user_pda_current
SET archive_last_imported_tx_id=?
WHERE LOWER(login)=LOWER(?)
AND archive_head_tx_id=?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, importedTxId == null ? "" : importedTxId);
ps.setString(2, publisherLogin);
ps.setString(3, expectedHeadTxId);
return ps.executeUpdate() == 1;
}
}
/**
* Помечает именно текущий head как полностью импортированный.
* Если Solana users sync успел заменить head на более новый, update не произойдёт.
*/
public boolean markHeadImported(String publisherLogin, String expectedHeadTxId) throws SQLException {
String sql = """
UPDATE solana_user_pda_current
SET archive_imported=TRUE,
archive_last_imported_tx_id=archive_head_tx_id
WHERE LOWER(login)=LOWER(?)
AND archive_head_tx_id=?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, publisherLogin);
ps.setString(2, expectedHeadTxId);
return ps.executeUpdate() == 1;
}
}
public void upsertLocation(ArchiveBlockchainLocation e) throws SQLException {
String sql="""
INSERT INTO archive_blockchain_location(
blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number,
chunk_offset,chunk_size,source_last_block_number,updated_at_ms)
VALUES(?,?,?,?,?,?,?,?,?)
ON CONFLICT(blockchain_name) DO UPDATE SET
publisher_login=EXCLUDED.publisher_login,arweave_tx_id=EXCLUDED.arweave_tx_id,
archive_hash=EXCLUDED.archive_hash,big_block_number=EXCLUDED.big_block_number,
chunk_offset=EXCLUDED.chunk_offset,chunk_size=EXCLUDED.chunk_size,
source_last_block_number=EXCLUDED.source_last_block_number,updated_at_ms=EXCLUDED.updated_at_ms
WHERE EXCLUDED.source_last_block_number >= archive_blockchain_location.source_last_block_number
""";
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
ps.setString(1,e.blockchainName()); ps.setString(2,e.publisherLogin()); ps.setString(3,e.arweaveTxId());
ps.setBytes(4,e.archiveHash()); ps.setLong(5,e.bigBlockNumber()); ps.setLong(6,e.chunkOffset());
ps.setLong(7,e.chunkSize()); ps.setLong(8,e.sourceLastBlockNumber()); ps.setLong(9,e.updatedAtMs()); ps.executeUpdate();
}
}
public ArchiveBlockchainLocation getLocation(String blockchainName) throws SQLException {
String sql="""
SELECT blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number,
chunk_offset,chunk_size,source_last_block_number,updated_at_ms
FROM archive_blockchain_location WHERE LOWER(blockchain_name)=LOWER(?)
""";
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
ps.setString(1,blockchainName);
try(ResultSet rs=ps.executeQuery()) {
if(!rs.next()) return null;
return new ArchiveBlockchainLocation(rs.getString(1),rs.getString(2),rs.getString(3),rs.getBytes(4),
rs.getLong(5),rs.getLong(6),rs.getLong(7),rs.getLong(8),rs.getLong(9));
}
}
}
/** Данные PDA, нужные для создания пустого локального blockchain_state перед первым архивным импортом. */
public BlockchainIdentity getBlockchainIdentity(String blockchainName) throws SQLException {
String sql="""
SELECT login, blockchain_name, blockchain_key, paid_limit_bytes
FROM solana_user_pda_current WHERE LOWER(blockchain_name)=LOWER(?) LIMIT 1
""";
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
ps.setString(1,blockchainName);
try(ResultSet rs=ps.executeQuery()) {
if(!rs.next()) return null;
return new BlockchainIdentity(rs.getString(1),rs.getString(2),rs.getString(3),rs.getLong(4));
}
}
}
public record BlockchainIdentity(String login,String blockchainName,String blockchainKey,long sizeLimit) {}
}
@@ -1,472 +0,0 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchiveChainCursor;
import shine.db.archive.ArchivePublishJob;
import shine.db.archive.ArchivePublishJobChain;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/**
* DAO архивного publisher-а. Все курсоры считаются подтверждёнными только после Solana finalized.
*/
public final class ArchivePublicationDAO {
private static volatile ArchivePublicationDAO instance;
private final DbController db = DbController.getInstance();
private ArchivePublicationDAO() {}
public static ArchivePublicationDAO getInstance() {
if (instance == null) {
synchronized (ArchivePublicationDAO.class) {
if (instance == null) instance = new ArchivePublicationDAO();
}
}
return instance;
}
public ArchiveChainCursor getCursor(String blockchainName) throws SQLException {
try (Connection c = db.getConnection()) {
return getCursor(c, blockchainName);
}
}
public ArchiveChainCursor getCursor(Connection c, String blockchainName) throws SQLException {
String sql = """
SELECT blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
last_archive_big_block_number, last_archive_big_block_hash,
last_chunk_offset, last_chunk_size, updated_at_ms
FROM archive_chain_cursor
WHERE blockchain_name = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, blockchainName);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapCursor(rs);
}
}
}
public long nextBigBlockNumber() throws SQLException {
String sql = """
SELECT COALESCE(MAX(big_block_number), 0) + 1
FROM archive_publish_job
WHERE status = 'CURSORS_COMMITTED'
""";
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getLong(1);
}
}
public List<ArchiveBigBlockRef> listFinalizedBigBlocks() throws SQLException {
String sql = """
SELECT big_block_number, archive_hash, arweave_tx_id
FROM archive_publish_job
WHERE status = 'CURSORS_COMMITTED'
AND archive_hash IS NOT NULL
AND arweave_tx_id IS NOT NULL
ORDER BY big_block_number ASC
""";
List<ArchiveBigBlockRef> out = new ArrayList<>();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
out.add(new ArchiveBigBlockRef(
rs.getLong("big_block_number"),
rs.getBytes("archive_hash"),
rs.getBytes("arweave_tx_id")
));
}
}
return out;
}
public ArchivePublishJob findUnfinishedJob() throws SQLException {
String sql = """
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
error_text, updated_at_ms
FROM archive_publish_job
WHERE status NOT IN ('CURSORS_COMMITTED', 'FAILED')
ORDER BY id ASC
LIMIT 1
""";
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapJob(rs) : null;
}
}
public ArchivePublishJob getJob(long id) throws SQLException {
String sql = """
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
error_text, updated_at_ms
FROM archive_publish_job
WHERE id = ?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapJob(rs) : null;
}
}
}
/** Атомарно создаёт frozen archive job и все его blockchain ranges. */
public long createJobWithChains(long bigBlockNumber, long createdAtMs, List<ArchivePublishJobChain> chains) throws SQLException {
if (chains == null || chains.isEmpty()) throw new IllegalArgumentException("Archive job без chains не создаётся");
try (Connection c = db.getConnection()) {
boolean oldAuto = c.getAutoCommit();
c.setAutoCommit(false);
try {
long jobId;
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
RETURNING id
""")) {
ps.setLong(1, bigBlockNumber);
ps.setLong(2, createdAtMs);
ps.setLong(3, createdAtMs);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
jobId = rs.getLong(1);
}
}
String sql = """
INSERT INTO archive_publish_job_chain(
job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size, new_chunk_offset, new_chunk_size
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
for (ArchivePublishJobChain ch : chains) {
bindJobChain(ps, new ArchivePublishJobChain(
jobId, ch.blockchainName(), ch.fromSourceBlockNumber(), ch.toSourceBlockNumber(),
ch.previousSourceBlockHash(), ch.lastSourceBlockHash(),
ch.previousArchiveBigBlockNumber(), ch.previousArchiveBigBlockHash(),
ch.previousChunkOffset(), ch.previousChunkSize(), null, null));
ps.addBatch();
}
ps.executeBatch();
}
c.commit();
return jobId;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException se) throw se;
throw new SQLException("Не удалось создать frozen archive job", e);
} finally {
c.setAutoCommit(oldAuto);
}
}
}
public long createJob(long bigBlockNumber, long createdAtMs) throws SQLException {
String sql = """
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
RETURNING id
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, bigBlockNumber);
ps.setLong(2, createdAtMs);
ps.setLong(3, createdAtMs);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
return rs.getLong(1);
}
}
}
public void insertJobChain(ArchivePublishJobChain e) throws SQLException {
String sql = """
INSERT INTO archive_publish_job_chain(
job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size,
new_chunk_offset, new_chunk_size
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
bindJobChain(ps, e);
ps.executeUpdate();
}
}
public List<ArchivePublishJobChain> listJobChains(long jobId) throws SQLException {
String sql = """
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size,
new_chunk_offset, new_chunk_size
FROM archive_publish_job_chain
WHERE job_id = ?
ORDER BY blockchain_name ASC
""";
List<ArchivePublishJobChain> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, jobId);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapJobChain(rs));
}
}
return out;
}
public void updateNewChunkPosition(long jobId, String blockchainName, long offset, long size) throws SQLException {
String sql = """
UPDATE archive_publish_job_chain
SET new_chunk_offset=?, new_chunk_size=?
WHERE job_id=? AND blockchain_name=?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, offset);
ps.setLong(2, size);
ps.setLong(3, jobId);
ps.setString(4, blockchainName);
if (ps.executeUpdate() != 1) throw new SQLException("Не найдена archive_publish_job_chain для " + blockchainName);
}
}
public void markFileBuilt(long jobId, String localPath, byte[] hash) throws SQLException {
updateJob(jobId, "FILE_BUILT", "local_archive_path=? , archive_hash=?", ps -> {
ps.setString(1, localPath);
ps.setBytes(2, hash);
});
}
public void markArweaveUploaded(long jobId, byte[] txId) throws SQLException {
updateJob(jobId, "ARWEAVE_UPLOADED", "arweave_tx_id=?", ps -> ps.setBytes(1, txId));
}
/** Обновляет только локальное имя/путь уже собранного archive-файла. */
public void updateLocalArchivePath(long jobId, String localPath) throws SQLException {
updateJob(jobId, null, "local_archive_path=?", ps -> ps.setString(1, localPath));
}
public void markArweaveConfirmed(long jobId, int confirmations) throws SQLException {
updateJob(jobId, "ARWEAVE_CONFIRMED", "arweave_confirmations=?", ps -> ps.setInt(1, confirmations));
}
public void markSolanaSubmitted(long jobId, String signature) throws SQLException {
updateJob(jobId, "SOLANA_SUBMITTED", "solana_signature=?", ps -> ps.setString(1, signature));
}
public void markSolanaFinalized(long jobId) throws SQLException {
updateJob(jobId, "SOLANA_FINALIZED", null, null);
}
public void markFailed(long jobId, String error) throws SQLException {
updateJob(jobId, "FAILED", "error_text=?", ps -> ps.setString(1, error == null ? "" : error));
}
/**
* После Solana finalized атомарно переводит курсоры всех blockchain на новый chunk и закрывает job.
*/
public void commitCursors(long jobId, byte[] bigBlockHash) throws SQLException {
try (Connection c = db.getConnection()) {
boolean oldAuto = c.getAutoCommit();
c.setAutoCommit(false);
try {
ArchivePublishJob job = getJob(c, jobId);
if (job == null) throw new SQLException("Archive job не найден: " + jobId);
List<ArchivePublishJobChain> chains = listJobChains(c, jobId);
long now = System.currentTimeMillis();
String upsert = """
INSERT INTO archive_chain_cursor(
blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
last_archive_big_block_number, last_archive_big_block_hash,
last_chunk_offset, last_chunk_size, updated_at_ms
) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(blockchain_name) DO UPDATE SET
last_archived_source_block_number=EXCLUDED.last_archived_source_block_number,
last_archived_source_block_hash=EXCLUDED.last_archived_source_block_hash,
last_archive_big_block_number=EXCLUDED.last_archive_big_block_number,
last_archive_big_block_hash=EXCLUDED.last_archive_big_block_hash,
last_chunk_offset=EXCLUDED.last_chunk_offset,
last_chunk_size=EXCLUDED.last_chunk_size,
updated_at_ms=EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(upsert)) {
for (ArchivePublishJobChain ch : chains) {
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) {
throw new SQLException("Не сохранены координаты нового chunk: " + ch.blockchainName());
}
ps.setString(1, ch.blockchainName());
ps.setLong(2, ch.toSourceBlockNumber());
ps.setBytes(3, ch.lastSourceBlockHash());
ps.setLong(4, job.bigBlockNumber());
ps.setBytes(5, bigBlockHash);
ps.setLong(6, ch.newChunkOffset());
ps.setLong(7, ch.newChunkSize());
ps.setLong(8, now);
ps.addBatch();
}
ps.executeBatch();
}
try (PreparedStatement ps = c.prepareStatement(
"UPDATE archive_publish_job SET status='CURSORS_COMMITTED', updated_at_ms=? WHERE id=?")) {
ps.setLong(1, now);
ps.setLong(2, jobId);
ps.executeUpdate();
}
c.commit();
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException se) throw se;
throw new SQLException("Не удалось commit archive cursors", e);
} finally {
c.setAutoCommit(oldAuto);
}
}
}
private ArchivePublishJob getJob(Connection c, long id) throws SQLException {
String sql = """
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
error_text, updated_at_ms
FROM archive_publish_job WHERE id=?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? mapJob(rs) : null; }
}
}
private List<ArchivePublishJobChain> listJobChains(Connection c, long jobId) throws SQLException {
String sql = """
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size,
new_chunk_offset, new_chunk_size
FROM archive_publish_job_chain
WHERE job_id=? ORDER BY blockchain_name ASC
""";
List<ArchivePublishJobChain> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, jobId);
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapJobChain(rs)); }
}
return out;
}
@FunctionalInterface
private interface Binder { void bind(PreparedStatement ps) throws SQLException; }
private void updateJob(long jobId, String status, String setFragment, Binder binder) throws SQLException {
if (setFragment != null && !setFragment.isBlank()) {
try (Connection c = db.getConnection()) {
updateJobWithExtra(c, jobId, status, setFragment, binder);
}
return;
}
String sql = status == null
? "UPDATE archive_publish_job SET updated_at_ms=? WHERE id=?"
: "UPDATE archive_publish_job SET status=?, updated_at_ms=? WHERE id=?";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
if (status != null) ps.setString(i++, status);
ps.setLong(i++, System.currentTimeMillis());
ps.setLong(i, jobId);
ps.executeUpdate();
}
}
private void updateJobWithExtra(Connection c, long jobId, String status, String setFragment, Binder binder) throws SQLException {
String statusSql = status == null ? "" : ", status=?";
String sql = "UPDATE archive_publish_job SET " + setFragment + statusSql + ", updated_at_ms=? WHERE id=?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
binder.bind(ps);
int count = countParameters(setFragment);
int i = count + 1;
if (status != null) ps.setString(i++, status);
ps.setLong(i++, System.currentTimeMillis());
ps.setLong(i, jobId);
ps.executeUpdate();
}
}
private static int countParameters(String sqlPart) {
int n = 0;
for (int i = 0; i < sqlPart.length(); i++) if (sqlPart.charAt(i) == '?') n++;
return n;
}
private static void bindJobChain(PreparedStatement ps, ArchivePublishJobChain e) throws SQLException {
int i=1;
ps.setLong(i++, e.jobId());
ps.setString(i++, e.blockchainName());
ps.setLong(i++, e.fromSourceBlockNumber());
ps.setLong(i++, e.toSourceBlockNumber());
ps.setBytes(i++, e.previousSourceBlockHash());
ps.setBytes(i++, e.lastSourceBlockHash());
setLongNullable(ps, i++, e.previousArchiveBigBlockNumber());
ps.setBytes(i++, e.previousArchiveBigBlockHash());
setLongNullable(ps, i++, e.previousChunkOffset());
setLongNullable(ps, i++, e.previousChunkSize());
setLongNullable(ps, i++, e.newChunkOffset());
setLongNullable(ps, i, e.newChunkSize());
}
private static ArchiveChainCursor mapCursor(ResultSet rs) throws SQLException {
return new ArchiveChainCursor(
rs.getString("blockchain_name"),
rs.getLong("last_archived_source_block_number"),
rs.getBytes("last_archived_source_block_hash"),
rs.getLong("last_archive_big_block_number"),
rs.getBytes("last_archive_big_block_hash"),
rs.getLong("last_chunk_offset"),
rs.getLong("last_chunk_size"),
rs.getLong("updated_at_ms")
);
}
private static ArchivePublishJob mapJob(ResultSet rs) throws SQLException {
int conf = rs.getInt("arweave_confirmations");
Integer confObj = rs.wasNull() ? null : conf;
return new ArchivePublishJob(
rs.getLong("id"), rs.getLong("big_block_number"), rs.getString("status"),
rs.getLong("created_at_ms"), rs.getString("local_archive_path"),
rs.getBytes("archive_hash"), rs.getBytes("arweave_tx_id"), confObj,
rs.getString("solana_signature"), rs.getString("error_text"), rs.getLong("updated_at_ms")
);
}
private static ArchivePublishJobChain mapJobChain(ResultSet rs) throws SQLException {
return new ArchivePublishJobChain(
rs.getLong("job_id"), rs.getString("blockchain_name"),
rs.getLong("from_source_block_number"), rs.getLong("to_source_block_number"),
rs.getBytes("previous_source_block_hash"), rs.getBytes("last_source_block_hash"),
getLongNullable(rs, "previous_archive_big_block_number"), rs.getBytes("previous_archive_big_block_hash"),
getLongNullable(rs, "previous_chunk_offset"), getLongNullable(rs, "previous_chunk_size"),
getLongNullable(rs, "new_chunk_offset"), getLongNullable(rs, "new_chunk_size")
);
}
private static Long getLongNullable(ResultSet rs, String name) throws SQLException {
long v = rs.getLong(name);
return rs.wasNull() ? null : v;
}
private static void setLongNullable(PreparedStatement ps, int idx, Long v) throws SQLException {
if (v == null) ps.setNull(idx, Types.BIGINT); else ps.setLong(idx, v);
}
}
@@ -0,0 +1,138 @@
package shine.db.dao;
import shine.db.DbController;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/** Persistent discovery/import queue for ANS-104 SHiNE blocks found through Arweave. */
public final class ArweaveBlockImportDAO {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_REJECTED = "REJECTED";
public record QueueItem(byte[] dataItemId, String rootTxId, long blockHeight,
byte[] rawDataItem, String status, String lastError,
long firstSeenAtMs, long updatedAtMs) {}
private static volatile ArweaveBlockImportDAO instance;
private final DbController db = DbController.getInstance();
private ArweaveBlockImportDAO() {}
public static ArweaveBlockImportDAO getInstance() {
if (instance == null) {
synchronized (ArweaveBlockImportDAO.class) {
if (instance == null) instance = new ArweaveBlockImportDAO();
}
}
return instance;
}
public long getLastBlockHeight() throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("SELECT last_block_height FROM arweave_block_sync_state WHERE id=1");
ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getLong(1) : 0L;
}
}
public void setLastBlockHeight(long height, long nowMs) throws SQLException {
String sql = """
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
VALUES(1,?,?)
ON CONFLICT(id) DO UPDATE SET last_block_height=EXCLUDED.last_block_height, updated_at_ms=EXCLUDED.updated_at_ms
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, Math.max(0L, height));
ps.setLong(2, nowMs);
ps.executeUpdate();
}
}
/** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */
public boolean enqueueIfMissing(byte[] dataItemId, String rootTxId, long blockHeight, byte[] rawDataItem, long nowMs)
throws SQLException {
String sql = """
INSERT INTO arweave_block_import_queue(
data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
) VALUES(?,?,?,?,?,'',?,?)
ON CONFLICT(data_item_id) DO NOTHING
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setBytes(1, dataItemId);
ps.setString(2, rootTxId);
ps.setLong(3, blockHeight);
ps.setBytes(4, rawDataItem);
ps.setString(5, STATUS_PENDING);
ps.setLong(6, nowMs);
ps.setLong(7, nowMs);
return ps.executeUpdate() > 0;
}
}
public boolean exists(byte[] dataItemId) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM arweave_block_import_queue WHERE data_item_id=? LIMIT 1")) {
ps.setBytes(1, dataItemId);
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
}
}
public List<QueueItem> listPending(int limit) throws SQLException {
int safeLimit = Math.max(1, Math.min(limit, 100_000));
String sql = """
SELECT data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
FROM arweave_block_import_queue
WHERE status=?
ORDER BY block_height ASC, first_seen_at_ms ASC
LIMIT ?
""";
List<QueueItem> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, STATUS_PENDING);
ps.setInt(2, safeLimit);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
out.add(new QueueItem(
rs.getBytes("data_item_id"), rs.getString("root_tx_id"), rs.getLong("block_height"),
rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"),
rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms")));
}
}
}
return out;
}
public void setPendingError(byte[] id, String error, long nowMs) throws SQLException {
updateStatus(id, STATUS_PENDING, error, nowMs);
}
public void reject(byte[] id, String error, long nowMs) throws SQLException {
updateStatus(id, STATUS_REJECTED, error, nowMs);
}
public void delete(byte[] id) throws SQLException {
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
"DELETE FROM arweave_block_import_queue WHERE data_item_id=?")) {
ps.setBytes(1, id);
ps.executeUpdate();
}
}
private void updateStatus(byte[] id, String status, String error, long nowMs) throws SQLException {
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
"UPDATE arweave_block_import_queue SET status=?,last_error=?,updated_at_ms=? WHERE data_item_id=?")) {
ps.setString(1, status);
ps.setString(2, trimError(error));
ps.setLong(3, nowMs);
ps.setBytes(4, id);
ps.executeUpdate();
}
}
private static String trimError(String s) {
String v = s == null ? "" : s.replace('\n',' ').replace('\r',' ').trim();
return v.length() <= 500 ? v : v.substring(0,500);
}
}
@@ -23,7 +23,7 @@ import java.sql.SQLException;
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
* - пользовательские blockchain-файлы больше не существуют; cleanup полностью DB-only.
* SQL-транзакция и файловая система не коммитятся атомарно вместе;
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
* отдельным recovery/resync-слоем после успешного commit.
@@ -71,7 +71,7 @@ public final class BlockchainResyncCleanupDAO {
* - если blockchain_state уже отсутствует, login берём из blockchainName.
*
* Отдельно важно:
* - здесь НЕТ удаления .bch/.tmp_bch;
* - filesystem blockchain storage отсутствует;
* - здесь НЕТ повторной загрузки цепочки;
* - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow.
*/
@@ -9,286 +9,147 @@ import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/**
* DAO для таблицы blocks (новый формат).
*
* Правило:
* - методы с Connection НЕ закрывают соединение
* - методы без Connection сами открывают и закрывают соединение
*
* Ключ:
* - (bch_name, block_number) уникальная пара в рамках общей БД сервера.
*/
/** DAO for SHiNE Frame v1 blocks. block_bytes contains the complete ANS-104 DataItem. */
public final class BlocksDAO {
private static volatile BlocksDAO instance;
private final DbController db = DbController.getInstance();
private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class);
private BlocksDAO() { }
public static BlocksDAO getInstance() {
if (instance == null) {
synchronized (BlocksDAO.class) {
if (instance == null) instance = new BlocksDAO();
}
}
if (instance == null) synchronized (BlocksDAO.class) { if (instance == null) instance = new BlocksDAO(); }
return instance;
}
// -------------------- INSERT --------------------
/** Вставка с внешним соединением. Соединение НЕ закрывает. */
public void insert(Connection c, BlockEntry e) throws SQLException {
log.info("DBG BlockEntry: type={} sub={} lineCode={} prevLineNumber={} thisLineNumber={} prevLineHashLen={}",
e.getMsgType(), e.getMsgSubType(),
e.getLineCode(), e.getPrevLineNumber(), e.getThisLineNumber(),
e.getPrevLineHash() == null ? null : e.getPrevLineHash().length
);
String sql = """
INSERT INTO blocks (
login,
bch_name,
block_number,
msg_type,
msg_sub_type,
block_bytes,
to_login,
to_bch_name,
to_block_number,
to_block_hash,
block_hash,
block_signature,
edited_by_block_number,
line_code,
prev_line_number,
prev_line_hash,
this_line_number
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_bch_name,to_block_number,to_block_hash,
block_hash,block_signature,data_item_id,
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
ps.setString(i++, e.getLogin());
ps.setString(i++, e.getBchName());
ps.setInt(i++, e.getBlockNumber());
ps.setInt(i++, e.getMsgType());
ps.setInt(i++, e.getMsgSubType());
ps.setBytes(i++, e.getBlockBytes());
if (e.getToLogin() != null) ps.setString(i++, e.getToLogin());
else ps.setNull(i++, Types.VARCHAR);
if (e.getToBchName() != null) ps.setString(i++, e.getToBchName());
else ps.setNull(i++, Types.VARCHAR);
if (e.getToBlockNumber() != null) ps.setInt(i++, e.getToBlockNumber());
else ps.setNull(i++, Types.INTEGER);
if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash());
else ps.setNull(i++, Types.BINARY);
setNullableString(ps, i++, e.getToLogin());
setNullableString(ps, i++, e.getToBchName());
setNullableInt(ps, i++, e.getToBlockNumber());
setNullableBytes(ps, i++, e.getToBlockHash());
ps.setBytes(i++, e.getBlockHash());
ps.setBytes(i++, e.getBlockSignature());
if (e.getEditedByBlockNumber() != null) ps.setInt(i++, e.getEditedByBlockNumber());
else ps.setNull(i++, Types.INTEGER);
// NEW: line_code
if (e.getLineCode() != null) ps.setInt(i++, e.getLineCode());
else ps.setNull(i++, Types.INTEGER);
if (e.getPrevLineNumber() != null) ps.setInt(i++, e.getPrevLineNumber());
else ps.setNull(i++, Types.INTEGER);
if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash());
else ps.setNull(i++, Types.BINARY);
if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber());
else ps.setNull(i++, Types.INTEGER);
ps.setBytes(i++, e.getDataItemId());
ps.setBoolean(i++, e.isArweavePublishPending());
if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs());
setNullableString(ps, i++, e.getArweaveRootTxId());
setNullableInt(ps, i++, e.getEditedByBlockNumber());
setNullableInt(ps, i++, e.getLineCode());
setNullableInt(ps, i++, e.getPrevLineNumber());
setNullableBytes(ps, i++, e.getPrevLineHash());
setNullableInt(ps, i++, e.getThisLineNumber());
ps.executeUpdate();
}
}
/** Вставка без внешнего соединения. Сам открывает/закрывает. */
public void insert(BlockEntry e) throws SQLException {
try (Connection c = db.getConnection()) {
insert(c, e);
public void insert(BlockEntry e) throws SQLException { try (Connection c = db.getConnection()) { insert(c, e); } }
public boolean existsByDataItemId(byte[] dataItemId) throws SQLException {
if (dataItemId == null || dataItemId.length != 32) return false;
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM blocks WHERE data_item_id=? LIMIT 1")) {
ps.setBytes(1, dataItemId);
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
}
}
// -------------------- SELECT: HASH BY NUMBER --------------------
/** Получить block_hash по (bch_name, block_number). Нужен для линейной проверки. */
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
public List<BlockEntry> listPendingArweave(int limit) throws SQLException {
int safeLimit = Math.max(1, Math.min(limit, 100_000));
String sql = """
SELECT block_hash
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
FROM blocks
WHERE bch_name = ? AND block_number = ?
LIMIT 1
WHERE arweave_publish_pending = TRUE
ORDER BY bch_name ASC, block_number ASC
LIMIT ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bchName);
ps.setInt(2, blockNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return rs.getBytes("block_hash");
}
List<BlockEntry> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, safeLimit);
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
}
return out;
}
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException {
public void markArweavePublished(List<byte[]> dataItemIds, String rootTxId, long publishedAtMs) throws SQLException {
if (dataItemIds == null || dataItemIds.isEmpty()) return;
String sql = """
UPDATE blocks
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?, arweave_root_tx_id=?
WHERE data_item_id=?
""";
try (Connection c = db.getConnection()) {
return getHashByNumber(c, bchName, blockNumber);
c.setAutoCommit(false);
try (PreparedStatement ps = c.prepareStatement(sql)) {
for (byte[] id : dataItemIds) {
ps.setLong(1, publishedAtMs); ps.setString(2, rootTxId); ps.setBytes(3, id); ps.addBatch();
}
ps.executeBatch(); c.commit();
} catch (Exception e) { c.rollback(); throw e; }
finally { c.setAutoCommit(true); }
}
}
// -------------------- SELECT: FULL ENTRY --------------------
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("SELECT block_hash FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1")) {
ps.setString(1,bchName); ps.setInt(2,blockNumber);
try (ResultSet rs=ps.executeQuery()) { return rs.next()?rs.getBytes(1):null; }
}
}
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { try(Connection c=db.getConnection()){return getHashByNumber(c,bchName,blockNumber);} }
public BlockEntry getByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
String sql = """
SELECT
login,
bch_name,
block_number,
msg_type,
msg_sub_type,
block_bytes,
to_login,
to_bch_name,
to_block_number,
to_block_hash,
block_hash,
block_signature,
edited_by_block_number,
line_code,
prev_line_number,
prev_line_hash,
this_line_number
FROM blocks
WHERE bch_name = ? AND block_number = ?
LIMIT 1
""";
String sql = baseSelect()+" WHERE bch_name=? AND block_number=? LIMIT 1";
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,bchName); ps.setInt(2,blockNumber); try(ResultSet rs=ps.executeQuery()){return rs.next()?mapRow(rs):null;} }
}
public BlockEntry getByNumber(String bchName,int blockNumber)throws SQLException{try(Connection c=db.getConnection()){return getByNumber(c,bchName,blockNumber);} }
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bchName);
ps.setInt(2, blockNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
public List<BlockEntry> listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} }
public List<BlockEntry> listRangeByNumber(Connection c,String bchName,int from,int to)throws SQLException{
String sql=baseSelect()+" WHERE bch_name=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC";
List<BlockEntry> out=new ArrayList<>();
try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,bchName);ps.setInt(2,from);ps.setInt(3,to);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(mapRow(rs));}}
return out;
}
public BlockEntry getByNumber(String bchName, int blockNumber) throws SQLException {
try (Connection c = db.getConnection()) {
return getByNumber(c, bchName, blockNumber);
}
}
public List<BlockEntry> listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
try (Connection c = db.getConnection()) {
return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive);
}
}
public List<BlockEntry> listRangeByNumber(Connection c, String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
String sql = """
SELECT
login,
bch_name,
block_number,
msg_type,
msg_sub_type,
block_bytes,
to_login,
to_bch_name,
to_block_number,
to_block_hash,
block_hash,
block_signature,
edited_by_block_number,
line_code,
prev_line_number,
prev_line_hash,
this_line_number
FROM blocks
WHERE bch_name = ?
AND block_number >= ?
AND block_number <= ?
ORDER BY block_number ASC
""";
List<BlockEntry> result = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bchName);
ps.setInt(2, fromBlockNumberInclusive);
ps.setInt(3, toBlockNumberInclusive);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
result.add(mapRow(rs));
}
}
}
return result;
}
// -------------------- INTERNAL --------------------
private BlockEntry mapRow(ResultSet rs) throws SQLException {
BlockEntry e = new BlockEntry();
e.setLogin(rs.getString("login"));
e.setBchName(rs.getString("bch_name"));
e.setBlockNumber(rs.getInt("block_number"));
e.setMsgType(rs.getInt("msg_type"));
e.setMsgSubType(rs.getInt("msg_sub_type"));
e.setBlockBytes(rs.getBytes("block_bytes"));
String toLogin = rs.getString("to_login");
if (rs.wasNull()) toLogin = null;
e.setToLogin(toLogin);
String toBchName = rs.getString("to_bch_name");
if (rs.wasNull()) toBchName = null;
e.setToBchName(toBchName);
Integer toBlockNumber = (Integer) rs.getObject("to_block_number");
e.setToBlockNumber(toBlockNumber);
byte[] toHash = rs.getBytes("to_block_hash");
if (rs.wasNull()) toHash = null;
e.setToBlockHash(toHash);
e.setBlockHash(rs.getBytes("block_hash"));
e.setBlockSignature(rs.getBytes("block_signature"));
Integer editedBy = (Integer) rs.getObject("edited_by_block_number");
e.setEditedByBlockNumber(editedBy);
// NEW: line_code
Integer lineCode = (Integer) rs.getObject("line_code");
e.setLineCode(lineCode);
Integer prevLn = (Integer) rs.getObject("prev_line_number");
e.setPrevLineNumber(prevLn);
byte[] prevLh = rs.getBytes("prev_line_hash");
if (rs.wasNull()) prevLh = null;
e.setPrevLineHash(prevLh);
Integer thisLn = (Integer) rs.getObject("this_line_number");
e.setThisLineNumber(thisLn);
private static String baseSelect(){return """
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
FROM blocks
""";}
private BlockEntry mapRow(ResultSet rs)throws SQLException{
BlockEntry e=new BlockEntry();
e.setLogin(rs.getString("login")); e.setBchName(rs.getString("bch_name")); e.setBlockNumber(rs.getInt("block_number"));
e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes"));
e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash"));
e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id"));
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms")); e.setArweaveRootTxId(rs.getString("arweave_root_tx_id"));
e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number"));
return e;
}
private static void setNullableString(PreparedStatement ps,int i,String v)throws SQLException{if(v==null)ps.setNull(i,Types.VARCHAR);else ps.setString(i,v);}
private static void setNullableInt(PreparedStatement ps,int i,Integer v)throws SQLException{if(v==null)ps.setNull(i,Types.INTEGER);else ps.setInt(i,v);}
private static void setNullableBytes(PreparedStatement ps,int i,byte[] v)throws SQLException{if(v==null)ps.setNull(i,Types.BINARY);else ps.setBytes(i,v);}
}
@@ -120,4 +120,15 @@ public final class ChannelNameStateDAO {
insert(c, entry);
}
}
public String getSlugByOwnerAndRoot(String ownerBchName, int rootBlockNumber) throws SQLException {
String sql = "SELECT slug FROM channel_names_state WHERE owner_bch_name=? AND channel_root_block_number=? LIMIT 1";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBchName);
ps.setInt(2, rootBlockNumber);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString(1) : null;
}
}
}
}
@@ -8,6 +8,7 @@ import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Base64;
/**
* Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем.
@@ -60,6 +61,23 @@ public final class SolanaUserPdaCurrentDAO {
}
}
/** Resolve a current user by the 32-byte Ed25519 blockchain key used as ANS-104 owner. */
public SolanaUserPdaCurrentEntry getByBlockchainKey(byte[] owner32) throws SQLException {
if (owner32 == null || owner32.length != 32) return null;
String wanted = Base64.getEncoder().encodeToString(owner32);
String sql = """
SELECT login, blockchain_name, blockchain_key, paid_limit_bytes
FROM solana_user_pda_current
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql); ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
SolanaUserPdaCurrentEntry e = mapRow(rs);
if (wanted.equals(e.getBlockchainKey())) return e;
}
}
return null;
}
private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException {
SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry();
entry.setLogin(rs.getString("login"));
@@ -37,6 +37,10 @@ public class BlockEntry {
private byte[] blockHash;
private byte[] blockSignature;
private byte[] dataItemId;
private boolean arweavePublishPending;
private Long arweavePublishedAtMs;
private String arweaveRootTxId;
private Integer editedByBlockNumber;
@@ -85,6 +89,15 @@ public class BlockEntry {
public byte[] getBlockSignature() { return blockSignature; }
public void setBlockSignature(byte[] blockSignature) { this.blockSignature = blockSignature; }
public byte[] getDataItemId() { return dataItemId; }
public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; }
public boolean isArweavePublishPending() { return arweavePublishPending; }
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
public String getArweaveRootTxId() { return arweaveRootTxId; }
public void setArweaveRootTxId(String arweaveRootTxId) { this.arweaveRootTxId = arweaveRootTxId; }
public Integer getEditedByBlockNumber() { return editedByBlockNumber; }
public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; }
@@ -0,0 +1,56 @@
-- Frame v1 intentionally has no backward compatibility with the old signed block format.
-- Refuse to migrate a populated blockchain database: test/dev data must be reset first.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM blocks LIMIT 1)
OR EXISTS (SELECT 1 FROM blockchain_state WHERE last_block_number >= 0 LIMIT 1) THEN
RAISE EXCEPTION 'migration_v24 requires an empty blockchain database; reset test/dev blockchain data before upgrading';
END IF;
END $$;
-- Старый SHINE-ARCHIVE v1 больше не используется.
DROP TABLE IF EXISTS archive_publish_job_chain CASCADE;
DROP TABLE IF EXISTS archive_publish_job CASCADE;
DROP TABLE IF EXISTS archive_chain_cursor CASCADE;
DROP TABLE IF EXISTS archive_blockchain_location CASCADE;
-- SHiNE Frame v1 / ANS-104 DataItem storage. No legacy .bch files are required.
ALTER TABLE blocks
ADD COLUMN IF NOT EXISTS data_item_id BYTEA;
ALTER TABLE blocks
ADD COLUMN IF NOT EXISTS arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE blocks
ADD COLUMN IF NOT EXISTS arweave_published_at_ms BIGINT;
ALTER TABLE blocks
ADD COLUMN IF NOT EXISTS arweave_root_tx_id TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
ON blocks(data_item_id)
WHERE data_item_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
ON blocks(block_number)
WHERE arweave_publish_pending = TRUE;
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
id SMALLINT PRIMARY KEY CHECK (id = 1),
last_block_height BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL DEFAULT 0
);
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
VALUES(1,0,0) ON CONFLICT(id) DO NOTHING;
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
data_item_id BYTEA PRIMARY KEY,
root_tx_id TEXT NOT NULL,
block_height BIGINT NOT NULL,
raw_data_item BYTEA NOT NULL,
status TEXT NOT NULL,
last_error TEXT NOT NULL DEFAULT '',
first_seen_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
ON arweave_block_import_queue(block_height,first_seen_at_ms)
WHERE status='PENDING';
UPDATE db_schema_version SET schema_version=24, updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id=1;
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 20, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
VALUES (1, 24, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
@@ -291,12 +291,6 @@ AFTER TRUNCATE ON solana_user_pda_current
FOR EACH STATEMENT
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
CREATE TRIGGER trg_user_stats_state_ai
AFTER INSERT ON solana_user_pda_current
FOR EACH ROW
EXECUTE FUNCTION shine_user_stats_state_ai();
CREATE TABLE IF NOT EXISTS solana_user_pda_history (
id BIGSERIAL PRIMARY KEY,
tx_signature TEXT NOT NULL,
@@ -503,6 +497,10 @@ CREATE TABLE IF NOT EXISTS blocks (
to_block_hash BYTEA,
block_hash BYTEA NOT NULL,
block_signature BYTEA NOT NULL,
data_item_id BYTEA NOT NULL,
arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE,
arweave_published_at_ms BIGINT,
arweave_root_tx_id TEXT,
edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0),
line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0),
prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0),
@@ -514,12 +512,41 @@ CREATE TABLE IF NOT EXISTS blocks (
CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number
ON blocks (bch_name, block_number);
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
ON blocks(data_item_id);
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
ON blocks(block_number)
WHERE arweave_publish_pending = TRUE;
CREATE INDEX IF NOT EXISTS idx_blocks_to_target
ON blocks (to_login, to_bch_name, to_block_number);
CREATE INDEX IF NOT EXISTS idx_blocks_by_line
ON blocks (bch_name, line_code, this_line_number);
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
id SMALLINT PRIMARY KEY CHECK (id = 1),
last_block_height BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL DEFAULT 0
);
INSERT INTO arweave_block_sync_state(id, last_block_height, updated_at_ms)
VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
data_item_id BYTEA PRIMARY KEY,
root_tx_id TEXT NOT NULL,
block_height BIGINT NOT NULL,
raw_data_item BYTEA NOT NULL,
status TEXT NOT NULL,
last_error TEXT NOT NULL DEFAULT '',
first_seen_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
ON arweave_block_import_queue(block_height,first_seen_at_ms)
WHERE status='PENDING';
CREATE TABLE IF NOT EXISTS connections_state (
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
rel_type INTEGER NOT NULL,
@@ -1660,6 +1687,12 @@ AFTER INSERT ON blocks
FOR EACH ROW
EXECUTE FUNCTION shine_blocks_edit_apply_ai();
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
CREATE TRIGGER trg_user_stats_state_ai
AFTER INSERT ON solana_user_pda_current
FOR EACH ROW
EXECUTE FUNCTION shine_user_stats_state_ai();
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
CREATE TRIGGER trg_channel_names_state_stats_ai
AFTER INSERT ON channel_names_state
@@ -2028,52 +2061,6 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state (
PRIMARY KEY (owner_login, category)
);
CREATE TABLE IF NOT EXISTS archive_chain_cursor (
blockchain_name TEXT PRIMARY KEY,
last_archived_source_block_number BIGINT NOT NULL,
last_archived_source_block_hash BYTEA NOT NULL,
last_archive_big_block_number BIGINT NOT NULL,
last_archive_big_block_hash BYTEA NOT NULL,
last_chunk_offset BIGINT NOT NULL,
last_chunk_size BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS archive_publish_job (
id BIGSERIAL PRIMARY KEY,
big_block_number BIGINT NOT NULL,
status TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
local_archive_path TEXT,
archive_hash BYTEA,
arweave_tx_id BYTEA,
arweave_confirmations INTEGER,
solana_signature TEXT,
error_text TEXT,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status
ON archive_publish_job(status);
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number
ON archive_publish_job(big_block_number);
CREATE TABLE IF NOT EXISTS archive_publish_job_chain (
job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE,
blockchain_name TEXT NOT NULL,
from_source_block_number BIGINT NOT NULL,
to_source_block_number BIGINT NOT NULL,
previous_source_block_hash BYTEA NOT NULL,
last_source_block_hash BYTEA NOT NULL,
previous_archive_big_block_number BIGINT,
previous_archive_big_block_hash BYTEA,
previous_chunk_offset BIGINT,
previous_chunk_size BIGINT,
new_chunk_offset BIGINT,
new_chunk_size BIGINT,
PRIMARY KEY (job_id, blockchain_name)
);
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_current
@@ -2093,16 +2080,8 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
ON solana_user_pda_current(is_server, archive_imported)
WHERE archive_head_tx_id <> '';
CREATE TABLE IF NOT EXISTS archive_blockchain_location (
blockchain_name TEXT PRIMARY KEY, publisher_login TEXT NOT NULL, arweave_tx_id TEXT NOT NULL,
archive_hash BYTEA NOT NULL, big_block_number BIGINT NOT NULL, chunk_offset BIGINT NOT NULL,
chunk_size BIGINT NOT NULL, source_last_block_number BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher ON archive_blockchain_location(publisher_login);
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
VALUES(1,23,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
COMMIT;
@@ -42,10 +42,8 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetArchiveBlockchainLocation_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetArchiveBlockchainLocation_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
@@ -193,7 +191,6 @@ public final class JsonHandlerRegistry {
// --- blockchain ---
Map.entry("AddBlock", new Net_AddBlock_Handler()),
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
Map.entry("GetArchiveBlockchainLocation", new Net_GetArchiveBlockchainLocation_Handler()),
// --- userParams ---
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
@@ -286,7 +283,6 @@ public final class JsonHandlerRegistry {
// --- blockchain ---
Map.entry("AddBlock", Net_AddBlock_Request.class),
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
Map.entry("GetArchiveBlockchainLocation", Net_GetArchiveBlockchainLocation_Request.class),
// --- userParams ---
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
@@ -103,6 +103,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
req.getBlockNumber(), // старое поле, пока оставляем
req.getPrevBlockHash(), // старое поле, пока оставляем
req.getBlockBytesB64(),
true,
true
);
@@ -163,6 +164,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "limit_exceeded" -> "Превышен лимит размера блокчейна";
case "limit_check_failed" -> "Ошибка проверки лимита размера";
case "bad_block_format" -> "Некорректный формат блока";
case "bad_app_tag" -> "Некорректный тестовый тег ANS-104";
case "bad_channel_tag" -> "Некорректный тег канала ANS-104";
case "bad_block_body" -> "Некорректное тело блока";
case "bad_channel_name" -> "Некорректное название канала";
case "bad_block_number" -> "Некорректный номер блока";
@@ -190,7 +193,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
int globalNumberFromReq,
String prevGlobalHashHexFromReq,
String blockBytesB64,
boolean replicateAfterWrite
boolean replicateAfterWrite,
boolean publishToArweave
) {
if (blockchainName == null || blockchainName.isBlank()) {
log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq);
@@ -272,6 +276,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex);
}
if (!block.getDataItem().hasTag("App", "test5590")) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_app_tag", serverLastNum, serverLastHashHex);
}
// body.check()
try {
block.body.check();
@@ -401,6 +409,22 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
}
// Channel DataItems are indexed by a signed canonical channel slug tag: c=<slug>.
try {
String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry);
String actualChannelSlug = block.getDataItem().tagValue("c");
if (expectedChannelSlug != null) {
if (!expectedChannelSlug.equals(actualChannelSlug)) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
}
} else if (actualChannelSlug != null) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
}
} catch (Exception e) {
log.warn("AddBlock: cannot validate channel tag blockchainName={} block={}", blockchainName, block.blockNumber, e);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
}
if ((block.type & 0xFFFF) == 1
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
try {
@@ -458,7 +482,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex);
}
// 6) подпись по hash32(preimage)
// 6) ANS-104 Ed25519 подпись и owner
boolean sigOk;
try {
sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32);
@@ -516,7 +540,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
}
}
// 8) сформировать запись и записать (DB + state + файл)
// 8) сформировать запись и атомарно записать в PostgreSQL вместе с новым state
try {
BlockEntry be = new BlockEntry();
be.setLogin(login);
@@ -529,6 +553,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
be.setBlockBytes(block.toBytes());
be.setBlockHash(block.getHash32());
be.setBlockSignature(block.getSignature64());
be.setDataItemId(block.getDataItemId32());
be.setArweavePublishPending(publishToArweave);
// line columns (optional)
be.setLineCode(lineCode);
@@ -626,18 +652,26 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
* Архивный импорт использует ту же проверку и запись, что обычный AddBlock,
* но не запускает повторную межсерверную репликацию уже импортированного блока.
*/
public ArchiveImportResult addBlockFromArchive(String blockchainName, byte[] rawBlockBytes) {
public ArweaveImportResult addBlockFromArweave(String blockchainName, byte[] rawBlockBytes) {
if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) {
return new ArchiveImportResult(false, "chain_resync_in_progress", -1, "");
return new ArweaveImportResult(false, "chain_resync_in_progress", -1, "");
}
if (rawBlockBytes == null || rawBlockBytes.length == 0) {
return new ArchiveImportResult(false, "empty_block", -1, "");
return new ArweaveImportResult(false, "empty_block", -1, "");
}
try {
BchBlockEntry probe = new BchBlockEntry(rawBlockBytes);
if (blocksDAO.existsByDataItemId(probe.getDataItemId32())) {
return new ArweaveImportResult(true, "already_present", probe.blockNumber, toHex(probe.getHash32()));
}
} catch (Exception ignored) {
// Parsed again below to return the canonical bad_block_format result.
}
final BchBlockEntry parsed;
try {
parsed = new BchBlockEntry(rawBlockBytes);
} catch (Exception e) {
return new ArchiveImportResult(false, "bad_block_format", -1, "");
return new ArweaveImportResult(false, "bad_block_format", -1, "");
}
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
lock.lock();
@@ -647,20 +681,40 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
parsed.blockNumber,
toHex(parsed.prevHash32),
Base64Ws.encode(rawBlockBytes),
false,
false
);
return new ArchiveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
return new ArweaveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
} finally {
lock.unlock();
}
}
public record ArchiveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {}
public record ArweaveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {}
/* ===================================================================== */
/* ====================== Helpers ====================================== */
/* ===================================================================== */
private String expectedChannelSlug(String blockchainName, BchBlockEntry block, ChannelNameStateEntry justCreated) throws Exception {
if (block.body instanceof CreateChannelBody) {
return justCreated == null ? null : justCreated.getSlug();
}
int type = block.type & 0xffff;
int sub = block.subType & 0xffff;
boolean channelBlock = type == 1 && (
sub == (MsgSubType.TEXT_POST & 0xffff)
|| sub == (MsgSubType.TEXT_REPOST & 0xffff)
|| sub == (MsgSubType.TEXT_CHANNEL_META & 0xffff)
|| sub == (MsgSubType.TEXT_ENTRYPOINT & 0xffff)
|| sub == (MsgSubType.TEXT_EXERCISE & 0xffff)
|| sub == (MsgSubType.TEXT_SERVICE & 0xffff)
|| sub == (MsgSubType.TEXT_COURSE & 0xffff)
|| sub == (MsgSubType.TEXT_EDIT_POST & 0xffff));
if (!channelBlock || !(block.body instanceof BodyHasLine line) || line.lineCode() <= 0) return null;
return channelNameStateDAO.getSlugByOwnerAndRoot(blockchainName, line.lineCode());
}
private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) {
if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
&& subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null;
@@ -13,35 +13,17 @@ import shine.db.entities.BlockEntry;
import shine.db.entities.ChannelNameStateEntry;
import shine.db.entities.UserParamEntry;
import shine.db.entities.UserNotificationEntry;
import utils.files.FileStoreUtil;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HexFormat;
import java.util.ArrayList;
import java.util.List;
/**
* BlockchainWriter запись блока в БД и формирование файловой версии цепочки.
*
* Текущая схема:
* 1) собираем <blockchainName>.tmp_bch как готовый кандидат на замену основного файла;
* 2) пишем маленький sidecar <blockchainName>.write_check с blockNumber/blockHash;
* 3) создаём пустой marker <blockchainName>.write_pending;
* 4) выполняем одну SQL-транзакцию;
* 5) после commit атомарно заменяем основной .bch из tmp;
* 6) убираем временные файлы.
*
* Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись:
* - если marker отсутствует, а tmp/check остались, это мусор;
* - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор.
/** Stores the complete ANS-104 DataItem and all derived SHiNE state atomically in PostgreSQL.
* Local .bch/tmp/marker files are intentionally not used.
*/
public final class BlockchainWriter {
private static final HexFormat HEX = HexFormat.of();
private final BlocksDAO blocksDAO;
private final BlockchainStateDAO stateDAO;
@@ -49,7 +31,6 @@ public final class BlockchainWriter {
private final UserParamsDAO userParamsDAO;
private final UserNotificationsStateDAO userNotificationsStateDAO;
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
private final FileStoreUtil fs = FileStoreUtil.getInstance();
public BlockchainWriter(BlocksDAO blocksDAO,
BlockchainStateDAO stateDAO,
@@ -63,6 +44,10 @@ public final class BlockchainWriter {
this.userNotificationsStateDAO = userNotificationsStateDAO;
}
/**
* Stores the complete ANS-104 DataItem and all SQL projections in one DB transaction.
* There is intentionally no .bch/tmp/marker file layer anymore.
*/
public List<String> appendBlockAndState(String blockchainName,
BchBlockEntry block,
BlockchainStateEntry st,
@@ -73,48 +58,24 @@ public final class BlockchainWriter {
UserNotificationEntry notificationEntry) throws SQLException {
long nowMs = System.currentTimeMillis();
byte[] blockBytes = block.toBytes();
byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes);
String blockHashHex = HEX.formatHex(block.getHash32());
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
boolean committed = false;
byte[] rawDataItem = block.toBytes();
List<String> counterChangedLogins = new ArrayList<>();
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
c.setAutoCommit(false);
try {
// 1) insert block
blocksDAO.insert(c, be);
// 2) update state
st.setLastBlockNumber(block.blockNumber);
st.setLastBlockHash(block.getHash32());
st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length);
st.setFileSizeBytes(st.getFileSizeBytes() + rawDataItem.length);
st.setUpdatedAtMs(nowMs);
stateDAO.upsert(c, st);
// 2.1) Если блок USER_PARAM, синхронизируем снимок в users_params в той же транзакции.
if (userParamEntry != null) {
userParamsDAO.upsertIfNewer(c, userParamEntry);
}
if (userParamEntry != null) userParamsDAO.upsertIfNewer(c, userParamEntry);
if (channelNameStateEntry != null) channelNameStateDAO.insert(c, channelNameStateEntry);
if (channelMetaUpdateEntry != null) channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
if (notificationEntry != null) userNotificationsStateDAO.upsert(c, notificationEntry);
if (channelNameStateEntry != null) {
channelNameStateDAO.insert(c, channelNameStateEntry);
}
if (channelMetaUpdateEntry != null) {
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
}
// Notification projection is part of the same SQL transaction as the block.
// If notification indexing fails, the block/state write is rolled back as well.
if (notificationEntry != null) {
userNotificationsStateDAO.upsert(c, notificationEntry);
}
// Channel read/unread projection is updated in the same SQL transaction as the block.
int msgType = block.type & 0xFFFF;
int msgSubType = block.subType & 0xFFFF;
Integer lineCode = be.getLineCode();
@@ -126,7 +87,6 @@ public final class BlockchainWriter {
}
}
// Explicit UNFOLLOW removes the projection row. FOLLOW never creates it.
if (msgType == 3
&& msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
&& be.getToBchName() != null && !be.getToBchName().isBlank()
@@ -138,39 +98,14 @@ public final class BlockchainWriter {
}
c.commit();
committed = true;
} catch (Exception e) {
try {
c.rollback();
} catch (Exception ignored) {
}
if (!committed) {
cleanupWriteArtifactsBestEffort(blockchainName);
}
if (e instanceof SQLException se) {
throw se;
}
try { c.rollback(); } catch (Exception ignored) {}
if (e instanceof SQLException se) throw se;
throw new SQLException("appendBlockAndState failed", e);
} finally {
try {
c.setAutoCommit(true);
} catch (Exception ignored) {
}
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
}
// 3) После commit атомарно подменяем основной файл.
try {
fs.atomicReplaceBlockchainFile(blockchainName);
} catch (RuntimeException e) {
// marker/check/tmp оставляем для startup-recovery
throw e;
}
// 4) После успешной подмены чистим временные артефакты.
cleanupWriteArtifactsBestEffort(blockchainName);
return counterChangedLogins;
}
@@ -183,46 +118,4 @@ public final class BlockchainWriter {
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
}
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
byte[] base;
try {
if (Files.exists(fs.resolveBlockchainPath(blockchainName))) {
base = fs.readBlockchain(blockchainName);
} else {
base = new byte[0];
}
} catch (RuntimeException e) {
throw e;
}
byte[] out = new byte[base.length + blockBytes.length];
System.arraycopy(base, 0, out, 0, base.length);
System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length);
return out;
}
private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) {
fs.writeBlockchainTmp(blockchainName, candidateBytes);
fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex);
fs.writeBlockchainWritePendingMarker(blockchainName);
}
private void cleanupWriteArtifactsBestEffort(String blockchainName) {
deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName));
deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName));
deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName));
}
private static void deleteQuietly(DeleteAction action) {
try {
action.run();
} catch (RuntimeException ignored) {
}
}
@FunctionalInterface
private interface DeleteAction {
void run();
}
}
@@ -1,52 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.blockchain;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.*;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.*;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.archive.ArchiveBlockchainLocation;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchiveChainCursor;
import shine.db.dao.ArchiveImportDAO;
import shine.db.dao.ArchivePublicationDAO;
import utils.config.AppConfig;
import java.util.Base64;
public final class Net_GetArchiveBlockchainLocation_Handler implements JsonMessageHandler {
@Override public Net_Response handle(Net_Request base, ConnectionContext ctx) {
Net_GetArchiveBlockchainLocation_Request req=(Net_GetArchiveBlockchainLocation_Request)base;
String bch=String.valueOf(req.getBlockchainName()==null?"":req.getBlockchainName()).trim();
if(bch.isEmpty()) return NetExceptionResponseFactory.error(req,WireCodes.Status.BAD_REQUEST,"BAD_FIELDS","blockchainName обязателен");
try {
ArchiveImportDAO importDao = ArchiveImportDAO.getInstance();
ArchiveBlockchainLocation e=importDao.getLocation(bch);
if (e == null) {
// Backward-compatible fallback для publisher-а, который уже имел v22 cursors до появления v23 location index.
ArchivePublicationDAO publicationDao = ArchivePublicationDAO.getInstance();
ArchiveChainCursor cursor = publicationDao.getCursor(bch);
if (cursor != null) {
ArchiveBigBlockRef matching = publicationDao.listFinalizedBigBlocks().stream()
.filter(ref -> ref.bigBlockNumber() == cursor.lastArchiveBigBlockNumber())
.findFirst().orElse(null);
if (matching != null && matching.arweaveTxId() != null && matching.arweaveTxId().length == 32) {
String publisher = java.util.Objects.toString(AppConfig.getInstance().getParam("server.SHiNE.login"), "").trim();
String txId = Base64.getUrlEncoder().withoutPadding().encodeToString(matching.arweaveTxId());
e = new ArchiveBlockchainLocation(
bch, publisher, txId, cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(),
cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), System.currentTimeMillis());
importDao.upsertLocation(e);
}
}
}
if(e==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.NOT_FOUND,"ARCHIVE_LOCATION_NOT_FOUND","Архивная ссылка пока не известна этому серверу");
Net_GetArchiveBlockchainLocation_Response r=new Net_GetArchiveBlockchainLocation_Response();
r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK);
r.setBlockchainName(e.blockchainName()); r.setPublisherLogin(e.publisherLogin()); r.setArweaveTxId(e.arweaveTxId()); r.setArchiveHash(hex(e.archiveHash()));
r.setBigBlockNumber(e.bigBlockNumber()); r.setChunkOffset(e.chunkOffset()); r.setChunkSize(e.chunkSize()); r.setSourceLastBlockNumber(e.sourceLastBlockNumber()); return r;
} catch(Exception e) { return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"INTERNAL_ERROR",NetExceptionResponseFactory.detailedMessage("Не удалось получить архивную ссылку",e)); }
}
private static String hex(byte[] b){if(b==null)return"";StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
}
@@ -1,9 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public final class Net_GetArchiveBlockchainLocation_Request extends Net_Request {
private String blockchainName;
public String getBlockchainName(){return blockchainName;}
public void setBlockchainName(String value){this.blockchainName=value;}
}
@@ -1,16 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public final class Net_GetArchiveBlockchainLocation_Response extends Net_Response {
private String blockchainName,publisherLogin,arweaveTxId,archiveHash;
private long bigBlockNumber,chunkOffset,chunkSize,sourceLastBlockNumber;
public String getBlockchainName(){return blockchainName;} public void setBlockchainName(String v){blockchainName=v;}
public String getPublisherLogin(){return publisherLogin;} public void setPublisherLogin(String v){publisherLogin=v;}
public String getArweaveTxId(){return arweaveTxId;} public void setArweaveTxId(String v){arweaveTxId=v;}
public String getArchiveHash(){return archiveHash;} public void setArchiveHash(String v){archiveHash=v;}
public long getBigBlockNumber(){return bigBlockNumber;} public void setBigBlockNumber(long v){bigBlockNumber=v;}
public long getChunkOffset(){return chunkOffset;} public void setChunkOffset(long v){chunkOffset=v;}
public long getChunkSize(){return chunkSize;} public void setChunkSize(long v){chunkSize=v;}
public long getSourceLastBlockNumber(){return sourceLastBlockNumber;} public void setSourceLastBlockNumber(long v){sourceLastBlockNumber=v;}
}
@@ -4,7 +4,6 @@ import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import utils.files.FileStoreUtil;
/**
* In-memory guard для цепочек, которые сейчас находятся в полном resync.
@@ -39,10 +38,7 @@ public final class BlockchainResyncGuard {
if (isBypassed(key)) {
return false;
}
if (ACTIVE.contains(key)) {
return true;
}
return FileStoreUtil.getInstance().existsBlockchainResyncMarker(key);
return ACTIVE.contains(key);
}
public static <T> T withBypass(String blockchainName, ThrowingSupplier<T> supplier) throws Exception {
@@ -1,150 +0,0 @@
package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.entities.SyncServerEntry;
import utils.files.FileStoreUtil;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Startup-recovery для цепочек, которые были помечены как resync-pending.
*
* Правило простое:
* - marker-file означает, что chain должен быть пересобран заново;
* - мы не пытаемся продолжать с середины;
* - если recovery не завершён, обычная работа сервера не стартует.
*/
public final class BlockchainResyncRecoveryOnStartup {
private static final Logger log = LoggerFactory.getLogger(BlockchainResyncRecoveryOnStartup.class);
private static final RemoteBlockchainSyncClient REMOTE = new RemoteBlockchainSyncClient();
private BlockchainResyncRecoveryOnStartup() {}
public static void runRecoveryOrThrow() {
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
ensureDirExists(dataDir);
List<Path> markers = listMarkerFiles(dataDir);
if (markers.isEmpty()) {
log.info("🟢 BlockchainResyncRecovery: resync marker-файлы не найдены.");
return;
}
log.warn("🟡 BlockchainResyncRecovery: найдено marker-файлов: {}", markers.size());
for (Path marker : markers) {
recoverSingleMarkerOrThrow(marker);
}
log.info("✅ BlockchainResyncRecovery: все marker-файлы обработаны.");
}
private static void recoverSingleMarkerOrThrow(Path markerPath) {
String fileName = markerPath.getFileName().toString();
String blockchainName = extractBlockchainName(fileName);
if (blockchainName == null || blockchainName.isBlank()) {
throw new IllegalStateException("Bad resync marker name: " + fileName);
}
Map<String, String> meta = parseMarker(markerPath);
String partnerLogin = normalize(meta.get("partnerLogin"));
String partnerAddress = normalize(meta.get("partnerAddress"));
if (partnerAddress == null) {
throw new IllegalStateException("Resync marker has no partnerAddress for blockchainName=" + blockchainName);
}
log.warn("🔁 BlockchainResyncRecovery: processing marker blockchainName={} partnerLogin={} partnerAddress={}",
blockchainName, partnerLogin, partnerAddress);
try {
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads =
REMOTE.listBlockchainHeads(partnerLogin, partnerAddress);
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream()
.filter(h -> h != null && blockchainName.equals(h.blockchainName()))
.findFirst()
.orElseThrow(() -> new IllegalStateException(
"Partner does not expose blockchainName=" + blockchainName + " address=" + partnerAddress));
SyncServerEntry partner = new SyncServerEntry(
partnerLogin == null ? "" : partnerLogin,
partnerAddress,
System.currentTimeMillis()
);
PeriodicBlockchainSyncService.resyncFromScratch(partner, remoteHead);
} catch (Exception e) {
throw new IllegalStateException(
"Не удалось восстановить resync-помеченную цепочку blockchainName=" + blockchainName,
e
);
}
}
private static List<Path> listMarkerFiles(Path dataDir) {
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) {
return stream(ds).toList();
} catch (IOException e) {
throw new IllegalStateException("Cannot list resync markers in: " + dataDir, e);
}
}
private static java.util.stream.Stream<Path> stream(DirectoryStream<Path> ds) {
return java.util.stream.StreamSupport.stream(ds.spliterator(), false)
.filter(Files::isRegularFile);
}
private static Map<String, String> parseMarker(Path path) {
try {
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
Map<String, String> result = new HashMap<>();
for (String line : lines) {
if (line == null) continue;
String s = line.trim();
if (s.isEmpty() || s.startsWith("#")) continue;
int idx = s.indexOf('=');
if (idx <= 0) continue;
String key = s.substring(0, idx).trim();
String value = s.substring(idx + 1).trim();
result.put(key, value);
}
return result;
} catch (IOException e) {
throw new IllegalStateException("Cannot read resync marker: " + path, e);
}
}
private static void ensureDirExists(Path dir) {
try {
if (!Files.exists(dir)) {
Files.createDirectories(dir);
}
} catch (IOException e) {
throw new IllegalStateException("Cannot create data dir: " + dir, e);
}
}
private static String extractBlockchainName(String fileName) {
if (fileName == null) return null;
String s = fileName.trim();
if (!s.endsWith(FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) {
return null;
}
return s.substring(0, s.length() - FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION.length());
}
private static String normalize(String value) {
if (value == null) return null;
String s = value.trim();
return s.isEmpty() ? null : s.toLowerCase(Locale.ROOT);
}
}
@@ -15,7 +15,6 @@ import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.SolanaUserPdaCurrentEntry;
import shine.db.entities.SyncServerEntry;
import server.sync.BlockchainResyncGuard;
import utils.files.FileStoreUtil;
import utils.blockchain.BlockchainNameUtil;
import java.util.concurrent.locks.ReentrantLock;
@@ -52,7 +51,6 @@ public final class PeriodicBlockchainSyncService {
private static final BlockchainStateDAO STATE_DAO = BlockchainStateDAO.getInstance();
private static final SyncServersDAO SYNC_SERVERS_DAO = SyncServersDAO.getInstance();
private static final BlockchainResyncCleanupDAO RESYNC_CLEANUP_DAO = BlockchainResyncCleanupDAO.getInstance();
private static final FileStoreUtil FILE_STORE = FileStoreUtil.getInstance();
private static final SolanaUserPdaCurrentDAO SOLANA_USER_PDA_CURRENT_DAO = SolanaUserPdaCurrentDAO.getInstance();
private PeriodicBlockchainSyncService() {}
@@ -214,26 +212,6 @@ public final class PeriodicBlockchainSyncService {
return;
}
String markerContent = """
blockchainName=%s
partnerLogin=%s
partnerAddress=%s
remoteLastBlockNumber=%d
remoteLastBlockHash=%s
remoteFileSizeBytes=%d
startedAtMs=%d
""".formatted(
blockchainName,
partnerLogin == null ? "" : partnerLogin,
partner.getServerAddress() == null ? "" : partner.getServerAddress(),
remoteHead.lastBlockNumber(),
remoteHead.lastBlockHash(),
remoteHead.fileSizeBytes(),
System.currentTimeMillis()
);
FILE_STORE.writeBlockchainResyncMarker(blockchainName, markerContent);
ReentrantLock chainLock = BlockchainLocks.lockFor(blockchainName);
chainLock.lock();
boolean success = false;
@@ -248,9 +226,6 @@ public final class PeriodicBlockchainSyncService {
cleanup.deletedBlocksRows(),
cleanup.deletedBlockchainStateRows());
FILE_STORE.deleteBlockchainFileIfExists(blockchainName);
FILE_STORE.deleteBlockchainTmpFileIfExists(blockchainName);
if (!ensureLocalChainExists(partner, blockchainName)) {
throw new IllegalStateException("failed to recreate local chain state for " + blockchainName);
}
@@ -260,7 +235,6 @@ public final class PeriodicBlockchainSyncService {
throw new IllegalStateException("failed to replay remote chain for " + blockchainName);
}
FILE_STORE.deleteBlockchainResyncMarkerIfExists(blockchainName);
success = true;
log.info("Blockchain resync completed: partner={} blockchainName={} blocks=0..{}",
partnerLogin, blockchainName, remoteHead.lastBlockNumber());
@@ -58,7 +58,15 @@ public final class SolanaUsersSyncStartupService {
);
service.start();
service.awaitReady();
try {
service.awaitReady();
} catch (Exception exception) {
log.warn(
"Solana users sync is not ready yet; continuing server startup and leaving sync service running",
exception
);
return;
}
log.info(
"Solana users sync is READY. Server startup may continue."
@@ -1,348 +0,0 @@
package server.ws;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.dao.BlocksDAO;
import shine.db.dao.BlockchainStateDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.BlockchainStateEntry;
import utils.files.FileStoreUtil;
import shine.log.BlockchainAdminNotifier;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.HexFormat;
/**
* ===============================================================
* BlockchainTmpRecoveryOnStartup восстановление консистентности
* файлов обычного AddBlock при старте сервера.
*
* Новая модель обычной записи:
* 1) собирается <name>.tmp_bch как полный кандидат на замену main;
* 2) пишется sidecar <name>.write_check (blockNumber/blockHash);
* 3) создаётся пустой marker <name>.write_pending;
* 4) выполняется SQL-транзакция;
* 5) после commit tmp атомарно ставится на место main;
* 6) marker и sidecar удаляются.
*
* На старте:
* - если найден marker, recovery добивает запись или чистит мусор;
* - если marker нет, а tmp/check остались, это мусор и он удаляется;
* - legacy tmp-файлы без marker тоже считаются мусором.
*
* Принцип:
* - marker означает, что операция вошла в опасную фазу и должна быть доведена до конца или откатана;
* - sidecar нужен только как маленькое описание текущей операции (blockNumber/blockHash);
* - если marker отсутствует, временные файлы не считаются валидными.
* ===============================================================
*/
public final class BlockchainTmpRecoveryOnStartup {
private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class);
private static final HexFormat HEX = HexFormat.of();
private BlockchainTmpRecoveryOnStartup() {}
public static void runRecoveryOrThrow() {
FileStoreUtil fs = FileStoreUtil.getInstance();
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
BlocksDAO blocksDAO = BlocksDAO.getInstance();
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
ensureDirExists(dataDir);
List<Path> markers = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION);
if (!markers.isEmpty()) {
log.warn("🟡 BlockchainTmpRecovery: найдено marker-файлов обычного AddBlock: {}", markers.size());
} else {
log.info("🟢 BlockchainTmpRecovery: marker-файлов обычного AddBlock не найдено.");
}
for (Path marker : markers) {
recoverSingleWriteMarkerOrThrow(marker, fs, stateDAO, blocksDAO);
}
cleanupOrphanTempArtifacts(dataDir, fs);
log.info("✅ BlockchainTmpRecovery: обработка временных файлов AddBlock завершена.");
}
private static void recoverSingleWriteMarkerOrThrow(Path markerPath,
FileStoreUtil fs,
BlockchainStateDAO stateDAO,
BlocksDAO blocksDAO) {
String markerFileName = markerPath.getFileName().toString();
String blockchainName = extractBlockchainName(markerFileName, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION);
if (blockchainName == null || blockchainName.isBlank()) {
BlockchainAdminNotifier.critical(
"НАЙДЕН write_pending marker С НЕОЖИДАННЫМ ИМЕНЕМ: " + markerFileName,
null
);
throw new IllegalStateException("Bad write marker name: " + markerFileName);
}
Path tmpPath = fs.resolveBlockchainTmpPath(blockchainName);
Path checkPath = fs.resolveBlockchainWriteCheckPath(blockchainName);
Path mainPath = fs.resolveBlockchainPath(blockchainName);
Map<String, String> meta = parseKeyValueFile(checkPath);
Integer expectedBlockNumber = parseInt(meta.get("blockNumber"));
String expectedBlockHashHex = normalizeHex(meta.get("blockHash"));
try {
BlockchainStateEntry st = stateDAO.getByBlockchainName(blockchainName);
boolean mainExists = Files.exists(mainPath);
boolean tmpExists = Files.exists(tmpPath);
long mainSize = mainExists ? safeSize(mainPath) : -1L;
long tmpSize = tmpExists ? safeSize(tmpPath) : -1L;
if (st == null) {
log.warn("🟠 BlockchainTmpRecovery: marker есть, но blockchain_state отсутствует. blockchainName={}. Удаляем временные файлы.",
blockchainName);
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
return;
}
long stateSize = st.getFileSizeBytes();
if (expectedBlockNumber == null || expectedBlockHashHex == null) {
log.warn("🟠 BlockchainTmpRecovery: write_check повреждён или пуст. blockchainName={}. Пробуем recovery по размерам.",
blockchainName);
if (mainExists && mainSize == stateSize) {
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
return;
}
if (tmpExists && tmpSize == stateSize) {
fs.atomicReplaceBlockchainFile(blockchainName);
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
return;
}
if (tmpExists && mainExists) {
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
return;
}
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
return;
}
BlockEntry block = blocksDAO.getByNumber(blockchainName, expectedBlockNumber);
if (block == null || block.getBlockHash() == null) {
log.warn("🟠 BlockchainTmpRecovery: в blocks нет ожидаемого блока. blockchainName={}, blockNumber={}. Чистим временные файлы.",
blockchainName, expectedBlockNumber);
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
return;
}
String actualHashHex = HEX.formatHex(block.getBlockHash());
if (!actualHashHex.equalsIgnoreCase(expectedBlockHashHex)) {
log.warn("🟠 BlockchainTmpRecovery: hash в write_check не совпал с DB. blockchainName={}, expected={}, actual={}. Чистим временные файлы.",
blockchainName, expectedBlockHashHex, actualHashHex);
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
return;
}
// Если main уже совпадает со state tmp/check/marker лишние.
if (mainExists && mainSize == stateSize) {
log.info("🟢 BlockchainTmpRecovery: main уже соответствует state. blockchainName={}, stateSize={}, mainSize={}",
blockchainName, stateSize, mainSize);
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
return;
}
// Если tmp уже готов и совпадает со state просто ставим его на место main.
if (tmpExists && tmpSize == stateSize) {
log.warn("🟡 BlockchainTmpRecovery: tmp соответствует state, восстанавливаем main. blockchainName={}, stateSize={}, tmpSize={}",
blockchainName, stateSize, tmpSize);
fs.atomicReplaceBlockchainFile(blockchainName);
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
return;
}
// Если tmp нет, но DB уже закоммитила блок пробуем восстановить tmp из main + block_bytes.
if (!tmpExists && mainExists) {
long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length;
if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) {
log.warn("🟡 BlockchainTmpRecovery: tmp отсутствует, но main+DB дают валидный кандидат. blockchainName={}. Восстанавливаем tmp и main.",
blockchainName);
byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes());
fs.writeBlockchainTmp(blockchainName, rebuilt);
fs.atomicReplaceBlockchainFile(blockchainName);
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
return;
}
}
// Если tmp есть, но его размер не совпал, пробуем восстановить из main + block_bytes.
if (mainExists) {
long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length;
if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) {
log.warn("🟡 BlockchainTmpRecovery: tmp/size не совпали, пересобираем tmp из main+block_bytes. blockchainName={}",
blockchainName);
byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes());
fs.writeBlockchainTmp(blockchainName, rebuilt);
fs.atomicReplaceBlockchainFile(blockchainName);
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
return;
}
}
// Если ничего не совпало, это уже подозрительное состояние.
BlockchainAdminNotifier.critical(
"НЕСОГЛАСОВАННОСТЬ ОПЕРАЦИИ AddBlock ПРИ СТАРТЕ. blockchainName=" + blockchainName +
", stateSize=" + stateSize +
", mainExists=" + mainExists +
", mainSize=" + mainSize +
", tmpExists=" + tmpExists +
", tmpSize=" + tmpSize +
", expectedBlockNumber=" + expectedBlockNumber +
", expectedBlockHash=" + expectedBlockHashHex +
". Требуется ручная проверка.",
null
);
throw new IllegalStateException("AddBlock recovery mismatch for " + blockchainName);
} catch (SQLException e) {
BlockchainAdminNotifier.critical(
"ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ AddBlock marker: blockchainName=" + blockchainName,
e
);
throw new IllegalStateException("DB error during AddBlock recovery for " + blockchainName, e);
}
}
private static void cleanupOrphanTempArtifacts(Path dataDir, FileStoreUtil fs) {
List<Path> tmpFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION);
List<Path> checkFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION);
if (tmpFiles.isEmpty() && checkFiles.isEmpty()) {
return;
}
log.warn("🟡 BlockchainTmpRecovery: найдено orphan tmp/check файлов. tmp={}, check={}", tmpFiles.size(), checkFiles.size());
for (Path tmp : tmpFiles) {
String blockchainName = extractBlockchainName(tmp.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION);
if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) {
continue;
}
safeDelete(tmp);
}
for (Path check : checkFiles) {
String blockchainName = extractBlockchainName(check.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION);
if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) {
continue;
}
safeDelete(check);
}
}
private static void cleanupWriteArtifacts(Path markerPath, Path checkPath, Path tmpPath) {
safeDelete(markerPath);
safeDelete(checkPath);
safeDelete(tmpPath);
}
private static byte[] rebuildTmpFromMainAndBlock(Path mainPath, byte[] blockBytes) {
try {
byte[] mainBytes = Files.exists(mainPath) ? Files.readAllBytes(mainPath) : new byte[0];
byte[] out = new byte[mainBytes.length + blockBytes.length];
System.arraycopy(mainBytes, 0, out, 0, mainBytes.length);
System.arraycopy(blockBytes, 0, out, mainBytes.length, blockBytes.length);
return out;
} catch (IOException e) {
throw new IllegalStateException("Cannot rebuild tmp from main: " + mainPath, e);
}
}
private static Map<String, String> parseKeyValueFile(Path path) {
Map<String, String> result = new HashMap<>();
if (path == null || !Files.exists(path)) {
return result;
}
try {
for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) {
if (line == null) continue;
String s = line.trim();
if (s.isEmpty() || s.startsWith("#")) continue;
int idx = s.indexOf('=');
if (idx <= 0) continue;
String key = s.substring(0, idx).trim();
String value = s.substring(idx + 1).trim();
result.put(key, value);
}
return result;
} catch (IOException e) {
throw new IllegalStateException("Cannot read write_check file: " + path, e);
}
}
private static Integer parseInt(String value) {
if (value == null || value.isBlank()) return null;
try {
return Integer.parseInt(value.trim());
} catch (Exception e) {
return null;
}
}
private static String normalizeHex(String value) {
if (value == null) return null;
String s = value.trim();
return s.isEmpty() ? null : s;
}
private static void ensureDirExists(Path dir) {
try {
if (!Files.exists(dir)) {
Files.createDirectories(dir);
}
} catch (IOException e) {
throw new IllegalStateException("Cannot create data dir: " + dir, e);
}
}
private static List<Path> listFilesWithSuffix(Path dataDir, String suffix) {
List<Path> out = new ArrayList<>();
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + suffix)) {
for (Path p : ds) {
if (Files.isRegularFile(p)) {
out.add(p);
}
}
} catch (IOException e) {
throw new IllegalStateException("Cannot list files in: " + dataDir + " suffix=" + suffix, e);
}
return out;
}
private static String extractBlockchainName(String fileName, String suffix) {
if (fileName == null) return null;
String s = fileName.trim();
if (!s.endsWith(suffix)) return null;
String base = s.substring(0, s.length() - suffix.length());
if (base.isBlank()) return null;
if (base.contains("/") || base.contains("\\") || base.contains("..")) return null;
return base;
}
private static long safeSize(Path p) {
try {
return Files.size(p);
} catch (IOException e) {
throw new IllegalStateException("Cannot read file size: " + p, e);
}
}
private static void safeDelete(Path p) {
try {
Files.deleteIfExists(p);
} catch (IOException e) {
throw new IllegalStateException("Cannot delete file: " + p, e);
}
}
}
@@ -5,11 +5,10 @@ import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.archive.ArchivePublisherScheduler;
import server.archive.ArchiveImportScheduler;
import server.archive.ArweaveBlockPublisherScheduler;
import server.archive.ArweaveBlockSyncScheduler;
import server.debug.DebugApiConfigurator;
import server.files.DmFileApiConfigurator;
import server.sync.BlockchainResyncRecoveryOnStartup;
import server.sync.PeriodicBlockchainSyncService;
import server.sync.PeriodicDmDeliveryService;
import server.sync.SolanaUsersSyncStartupService;
@@ -22,10 +21,7 @@ import java.time.Duration;
/**
* WsServer поднимает Jetty WS на /ws.
*
* ВАЖНО:
* - перед стартом сервера выполняем recovery tmp-блокчейнов.
* - если обнаружена несогласованность, которую сервер сам чинить не может
* recovery бросает исключение и сервер не стартует.
* Пользовательские блоки хранятся в PostgreSQL; локальные .bch-файлы больше не используются.
*/
public final class WsServer {
@@ -33,28 +29,6 @@ public final class WsServer {
public static void main(String[] args) throws Exception {
// ============================================================
// 0) Восстановление консистентности blockchain файлов
// ============================================================
try {
BlockchainTmpRecoveryOnStartup.runRecoveryOrThrow();
} catch (Exception e) {
// Уже должно быть большое уведомление через BlockchainAdminNotifier,
// но на всякий случай логируем ещё раз.
log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain tmp-файлов.", e);
throw e; // останавливаем запуск
}
// ============================================================
// 0.1) Восстановление цепочек, зависших на full resync
// ============================================================
try {
BlockchainResyncRecoveryOnStartup.runRecoveryOrThrow();
} catch (Exception e) {
log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain resync-маркеров.", e);
throw e;
}
// ============================================================
// 1.0) Синхронизация пользовательских Solana PDA
// ============================================================
@@ -84,10 +58,9 @@ public final class WsServer {
// ============================================================
PeriodicBlockchainSyncService.startOrLog();
// Опциональная серверная публикация больших SHINE-ARCHIVE блоков.
ArchivePublisherScheduler.startOrLog();
// Импорт больших архивов только от publisher-ов из archive.import.allowedPublishers.
ArchiveImportScheduler.startOrLog();
// ANS-104: publish locally-created signed user blocks and discover blocks published by other servers.
ArweaveBlockPublisherScheduler.startOrLog();
ArweaveBlockSyncScheduler.startOrLog();
// ============================================================
// 2) Запуск Jetty WS
@@ -117,7 +90,7 @@ public final class WsServer {
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
ServerConnectionPool.getInstance().startOrLog();
Runtime.getRuntime().addShutdownHook(new Thread(
() -> { ServerConnectionPool.getInstance().close(); ArchivePublisherScheduler.close(); ArchiveImportScheduler.close(); },
() -> { ServerConnectionPool.getInstance().close(); ArweaveBlockPublisherScheduler.close(); ArweaveBlockSyncScheduler.close(); },
"server-connection-pool-shutdown"));
PeriodicDmDeliveryService.startOrLog();
server.join();
@@ -14,34 +14,6 @@ solana.users.sync.dbUser=
solana.users.sync.dbPassword=
solana.users.sync.pollIntervalSeconds=300
# ------------------------------------------------------------
# SHiNE archive publisher (Arweave + User PDA archive head)
# По умолчанию выключен: большинство серверов только читают/синхронизируют блоки.
# При включении готовые большие файлы сначала сохраняются локально в data/archive,
# затем загружаются в Arweave и только после подтверждения фиксируются в Solana.
# ------------------------------------------------------------
archive.publish.enabled=false
archive.publish.time=00:00
# Пусто = системная timezone сервера. Можно задать, например Europe/Warsaw.
archive.publish.zoneId=
archive.maxFileBytes=4000000000
archive.workDir=data/archive
archive.arweave.gateway=https://arweave.net
archive.arweave.walletJwkPath=
archive.arweave.minConfirmations=1
archive.arweave.confirmPollSeconds=30
archive.arweave.confirmTimeoutMinutes=180
# Для записи archive head используется уже существующий Solana RPC:
# solana.users.sync.rpcUrl (если задан), иначе solana.rpcUrl.
# Отдельного archive.solana.rpcUrl нет.
archive.solana.rootKeyPath=
archive.solana.clientKeyPath=
archive.solana.confirmPollSeconds=5
archive.solana.confirmTimeoutMinutes=30
archive.solana.commitment=finalized
# ------------------------------------------------------------
# Межсерверная синхронизация: как создавать локальную запись пользователя,
# если во время sync пришла чужая цепочка, а у нас такого login ещё нет.
@@ -156,8 +128,25 @@ test.freeAvatar.maxBytes=131072
test.freeAvatar.walletAddress=
test.freeAvatar.walletJwkPath=
# Trusted SHINE-ARCHIVE import. Empty list = do not trust/import archives from anyone.
# Comma-separated SHiNE server logins, case-insensitive.
archive.import.allowedPublishers=
archive.import.intervalMinutes=60
archive.import.workDir=data/archive-import
# ============================================================
# Arweave per-user-block transport (ANS-104)
# Test namespace: each user DataItem is signed with App=test5590.
# Channel DataItems additionally contain c=<canonical-channel-slug>.
# ============================================================
arweave.blocks.publish.enabled=false
arweave.blocks.publish.intervalMinutes=15
arweave.blocks.publish.maxItems=10000
arweave.blocks.publish.maxBundleBytes=134217728
arweave.blocks.publish.gateway=https://arweave.net
arweave.blocks.publish.walletJwkPath=
arweave.blocks.publish.minConfirmations=0
arweave.blocks.publish.confirmPollSeconds=30
arweave.blocks.publish.confirmTimeoutMinutes=180
arweave.blocks.sync.enabled=false
arweave.blocks.sync.intervalMinutes=15
arweave.blocks.sync.gateway=https://turbo-gateway.com
arweave.blocks.sync.pageSize=100
arweave.blocks.sync.queueBatchSize=10000
arweave.blocks.sync.startBlockHeight=0
arweave.blocks.sync.maxRootBundleBytes=268435456
@@ -1,5 +1,6 @@
package test.it.blockchain;
import blockchain.Ans104DataItem;
import blockchain.BchBlockEntry;
import blockchain.body.*;
import test.it.utils.TestConfig;
@@ -9,20 +10,19 @@ import test.it.utils.log.TestLog;
import test.it.utils.ws.WsSession;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* AddBlockSender под новый формат BchBlockEntry (Frame v0):
* - blockBytes = preimage + sigMarker(2) + signature64
* - preimage начинается с frameCode(2) = 0x0000
* - hash32 вычисляется как sha256(preimage)
* - signature = Ed25519.sign(hash32)
*
* ВАЖНО:
* - Линии по ТЗ ведём на стороне сервера/БД (триггеры), а в тестах считаем локально.
* AddBlockSender для Frame v1: blockBytesB64 содержит готовый ANS-104 DataItem.
* SHiNE hash цепочки остаётся SHA-256(Frame v1 bytes), а пользователь подписывает
* ANS-104 deep-hash одним Ed25519 ключом.
*/
public final class AddBlockSender {
@@ -34,6 +34,7 @@ public final class AddBlockSender {
private final String login;
private final String blockchainName;
private final byte[] loginPrivKey;
private final Map<Integer, String> channelSlugByRoot = new HashMap<>();
public AddBlockSender(WsSession ws, ChainState state, String login, String blockchainName, byte[] loginPrivKey) {
this.ws = ws;
@@ -75,22 +76,18 @@ public final class AddBlockSender {
byte[] bodyBytes = body.toBytes();
// ВАЖНО: preimage должен быть БАЙТ-В-БАЙТ таким же, как в BchBlockEntry
byte[] preimage = buildPreimage(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes);
byte[] frame = buildFrame(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes);
byte[] owner32 = utils.crypto.Ed25519Util.derivePublicKey(loginPrivKey);
byte[] hash32 = blockchain.BchCryptoVerifier.sha256(preimage);
byte[] signature64 = utils.crypto.Ed25519Util.sign(hash32, loginPrivKey);
List<Ans104DataItem.Tag> tags = new ArrayList<>();
tags.add(new Ans104DataItem.Tag("App", "test5590"));
String channelSlug = channelSlugFor(body);
if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c", channelSlug));
BchBlockEntry entry = new BchBlockEntry(
prevHash32,
blockNumber,
tsSec,
type,
subType,
version,
bodyBytes,
signature64
);
byte[] signingMessage = Ans104DataItem.buildSigningMessage(owner32, tags, frame);
byte[] signature64 = utils.crypto.Ed25519Util.sign(signingMessage, loginPrivKey);
byte[] rawDataItem = Ans104DataItem.serializeEd25519(signature64, owner32, tags, frame);
BchBlockEntry entry = new BchBlockEntry(rawDataItem);
String prevHashHexForReq = (blockNumber == 0) ? ZERO64 : state.lastBlockHashHex();
@@ -119,6 +116,9 @@ public final class AddBlockSender {
assertEquals(localHashHex, serverLastHash, op + ": serverLastBlockHash must match local hash");
state.applyAppendedBlock(blockNumber, entry.getHash32(), isHeader, type, body);
if (body instanceof CreateChannelBody ccb) {
channelSlugByRoot.put(blockNumber, shine.db.channels.ChannelNameRules.toCanonicalSlug(ccb.channelName));
}
if (TestConfig.DEBUG()) TestLog.info(op + ": state updated");
}
@@ -194,48 +194,47 @@ public final class AddBlockSender {
throw new IllegalArgumentException("Unknown body class: " + body.getClass());
}
// ---------- preimage builder (строго по BchBlockEntry Frame v0) ----------
// ---------- Frame v1 + signed channel tag helpers ----------
private static byte[] buildPreimage(byte[] prevHash32,
int blockNumber,
long tsSec,
short type,
short subType,
short version,
byte[] bodyBytes) {
private String channelSlugFor(BodyRecord body) {
if (body instanceof CreateChannelBody ccb) {
return shine.db.channels.ChannelNameRules.toCanonicalSlug(ccb.channelName);
}
if (body instanceof TextBody tb && tb.isLineMessage() && tb.lineCode > 0) {
String slug = channelSlugByRoot.get(tb.lineCode);
if (slug == null) {
throw new IllegalStateException("Unknown channel slug for lineCode=" + tb.lineCode);
}
return slug;
}
return null;
}
private static byte[] buildFrame(byte[] prevHash32,
int blockNumber,
long tsSec,
short type,
short subType,
short version,
byte[] bodyBytes) {
if (prevHash32 == null || prevHash32.length != 32) {
throw new IllegalArgumentException("prevHash32 must be 32 bytes");
}
int bodyLen = (bodyBytes == null ? 0 : bodyBytes.length);
int blockSize = BchBlockEntry.PREIMAGE_HEADER_SIZE + bodyLen;
int bodyLen = bodyBytes == null ? 0 : bodyBytes.length;
int blockSize = BchBlockEntry.FRAME_HEADER_SIZE + bodyLen;
java.nio.ByteBuffer bb = java.nio.ByteBuffer.allocate(blockSize).order(java.nio.ByteOrder.BIG_ENDIAN);
// [2] frameCode (v0)
bb.putShort((short) (BchBlockEntry.FRAME_CODE_V0 & 0xFFFF));
// [32] prevHash32
bb.putShort((short) BchBlockEntry.FRAME_CODE_V1);
bb.put(prevHash32);
// [4] blockSize (preimage size)
bb.putInt(blockSize);
// [4] blockNumber
bb.putInt(blockNumber);
// [8] timestamp
bb.putLong(tsSec);
// [2][2][2] type/subType/version
bb.putShort(type);
bb.putShort(subType);
bb.putShort(version);
// [N] bodyBytes
if (bodyBytes != null) bb.put(bodyBytes);
return bb.array();
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.12.19
server.version=1.10.7
client.version=1.12.20
server.version=1.10.8
+30 -291
View File
@@ -1,310 +1,49 @@
# API для разработчиков: 04 — Запись и чтение блока блокчейна
# AddBlock API
Документ описывает **текущий рабочий формат** сетевых вызовов:
## Назначение
- `AddBlock` — запись любого блока в блокчейн пользователя;
- `GetBlockchainBlock` — публичное чтение одного конкретного блока по имени цепочки и номеру.
Добавляет один готовый подписанный пользовательский SHiNE Frame v1 / ANS-104 DataItem в blockchain.
`GetBlockchainBlock` нужен в том числе для межсерверной синхронизации и для открытого чтения публичного блокчейна по одному блоку.
> Важный принцип: на уровне JSON API сейчас есть **один универсальный метод** записи — `AddBlock`.
> Конкретный смысл записи задаётся типом самого бинарного блока (`type/subType/version` в заголовке блока).
## 1. Что делает `AddBlock`
`AddBlock`:
- принимает имя блокчейна и base64 бинарного блока;
- проверяет непрерывность цепочки (`blockNumber`, `prevHash`);
- проверяет формат и подпись Ed25519;
- валидирует `body` по правилам типа блока;
- сохраняет блок и обновляет состояние цепочки.
## 2. JSON формат запроса
`op = "AddBlock"`.
## Request
```json
{
"op": "AddBlock",
"requestId": "req-1001",
"requestId": "...",
"payload": {
"blockchainName": "alice-001",
"blockNumber": 12,
"prevBlockHash": "ab12...ff",
"blockBytesB64": "AAAB..."
"blockchainName": "alice-...",
"blockNumber": 42,
"prevBlockHash": "64 hex chars",
"blockBytesB64": "..."
}
}
```
Поля `payload`:
- `blockchainName` — обязательно, формат `login-NNN`.
- `blockNumber` — обязательно (временное legacy-поле для совместимости; должно совпасть с номером внутри бинарного блока).
- `prevBlockHash` — legacy-поле, сейчас сервер использует `prevHash` из бинарного блока и состояние цепочки.
- `blockBytesB64` — обязательно: **полный бинарный блок** (`preimage + sigMarker + signature`) в Base64.
- `blockchainName` — целевая пользовательская chain;
- `blockNumber` — должен совпадать с номером внутри Frame v1 и быть `serverLast + 1`;
- `prevBlockHash` — 32-byte SHiNE hash предыдущего Frame в hex; должен совпадать с `prevHash32` внутри Frame и серверной вершиной;
- `blockBytesB64`**полный serialized ANS-104 DataItem** в Base64.
## 3. Успешный ответ
Старый формат `preimage + sigMarker + signature` не поддерживается.
```json
{
"op": "AddBlock",
"requestId": "req-1001",
"status": 200,
"ok": true,
"payload": {
"reasonCode": null,
"serverLastGlobalNumber": 12,
"serverLastGlobalHash": "9f0e...a1"
}
}
```
## Требования к DataItem
## 4. Ошибка (единый формат)
- generic Ed25519 signature type `2`;
- Ed25519 signature 64 bytes;
- owner 32 bytes и равен текущему blockchain public key;
- обязательный тестовый tag `App=test5590`;
- для channel block — `c=<canonical_channel_slug>`;
- `data` содержит SHiNE Frame v1 (`frameCode=1`).
При ошибках сервер отдаёт `Net_Exception_Response` со стандартными полями и дополнительно с состоянием сервера для ресинка:
## Основные ошибки
```json
{
"op": "AddBlock",
"requestId": "req-1001",
"status": 400,
"ok": false,
"error": "bad_prev_hash",
"message": "Некорректный prevHash (цепочка не совпадает)",
"payload": {
"serverLastGlobalNumber": 11,
"serverLastGlobalHash": "c3d4...98"
}
}
```
- `bad_app_tag` — отсутствует/неверен `App=test5590`;
- `bad_channel_tag``c` отсутствует, лишний или не совпадает с canonical slug;
- `bad_signature` / `signature_verify_failed` — DataItem не подписан текущим blockchain key;
- `bad_block_number` — нарушена последовательность;
- `bad_prev_hash` — нарушена SHiNE hash chain;
- `bad_block_bytes` — DataItem/Frame не парсится.
### Основные `reasonCode`
## Storage/publish
- `empty_blockchain_name`, `bad_blockchain_name`
- `blockchain_state_not_found`
- `bad_block_base64`, `bad_block_format`, `bad_block_body`
- `bad_block_number`, `req_global_mismatch`, `bad_prev_hash`
- `bad_signature`, `signature_verify_failed`
- `prev_line_block_not_found`, `bad_prev_line_hash`
- `limit_exceeded`
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
- `repost_disabled` — репосты временно отключены до будущей реализации
- `entrypoint_edit_forbidden``TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST`
- `status_confirmed_target_must_be_status_action``STATUS_CONFIRMED` должен ссылаться на статусный блок
- `status_action_target_not_allowed` — выбранный `STATUS_ACTION` нельзя ставить на этот тип материала
- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META`
- `internal_error`
## 5. Какие блоки реально можно добавлять через `AddBlock`
Через `AddBlock` можно писать поддержанные форматы, кроме явно отключённых временных фич:
1. **TECH (type=0)**
- `HEADER_COMPAT (subType=0)`
- `TECH_CREATE_CHANNEL (subType=1)`
2. **TEXT (type=1)**
- `TEXT_POST (10)`
- `TEXT_EDIT_POST (11)`
- `TEXT_REPLY (20)`
- `TEXT_EDIT_REPLY (21)`
- `TEXT_RATING (30)` — target-based отзыв на конкретный блок
- `TEXT_REPOST (50)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
- `TEXT_CHANNEL_META (90)` — скрытый технический снимок профиля канала
- `TEXT_ENTRYPOINT (100)` — входная страница канала
- `TEXT_EXERCISE (110)` — line-based материал упражнения
- `TEXT_SERVICE (120)` — line-based материал услуги / процедуры
- `TEXT_COURSE (130)` — line-based материал курса
3. **REACTION (type=2)**
- `REACTION_LIKE (1)`
4. **CONNECTION (type=3)**
- `CONNECTION_FRIEND (10)`
- `CONNECTION_UNFRIEND (11)`
- `CONNECTION_CONTACT (20)`
- `CONNECTION_UNCONTACT (21)`
- `CONNECTION_FOLLOW (30)`
- `CONNECTION_UNFOLLOW (31)`
- `CONNECTION_SPOUSE (40)`
- `CONNECTION_UNSPOUSE (41)`
- `CONNECTION_PARENT (50)`
- `CONNECTION_UNPARENT (51)`
- `CONNECTION_CHILD (52)`
- `CONNECTION_UNCHILD (53)`
- `CONNECTION_SIBLING (54)`
- `CONNECTION_UNSIBLING (55)`
- `CONNECTION_KNOWN_PERSON (60)`
- `CONNECTION_UNKNOWN_PERSON (61)`
- `CONNECTION_SHINE_CONFIRMED (70)`
- `CONNECTION_SHINE_UNCONFIRMED (71)`
- `CONNECTION_SHINE_SEEN (74)`
- `CONNECTION_SHINE_UNSEEN (75)`
5. **USER_PARAM (type=4)**
- `USER_PARAM_TEXT_TEXT (1)`
6. **STATUS_ACTION (type=5)**
- `STATUS_DONE_ONCE (10)`
- `STATUS_LEARNED (20)`
- `STATUS_SERVICE_PASSED (30)`
- `STATUS_CONFIRMED (100)`
- `STATUS_INTERESTED (110)`
- `STATUS_STARTED (120)`
- `STATUS_IN_STUDY (130)`
- `STATUS_ABANDONED (140)`
- `STATUS_COMPLETED (150)`
## 6. Практические payload-форматы для каналов и вложений
`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата.
### Вложения в сообщениях
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `S:att v=1`.
Пример текстового содержимого body:
```text
<S:att;v=1;nm=photo.jpg;sz=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
<S:att;v=1;nm=report.pdf;sz=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
Текст сообщения
```
Пример вложения с отдельным preview-файлом:
```text
<S:att;v=1;nm=video.mp4;sz=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;preAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;preSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
```
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
### Создание публичного канала с профилем
Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст:
```text
<S:title;v=1;Человекочитаемое имя канала>
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
Описание канала
```
Ограничение `channelDescription` — до `2048` UTF-8 байт. Новый клиент не пишет отдельный `TEXT_CHANNEL_META` сразу после создания канала: создание канала и начальный профиль должны попадать в один `TECH_CREATE_CHANNEL`.
### Изменение профиля канала
Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=90)`.
Текстовое содержимое body использует тот же формат полного снимка профиля:
```text
<S:title;v=1;Новое имя канала>
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
Новое описание канала
```
Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `S:ava` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`.
## 7. Хватает ли функций сейчас
Коротко: **для записи событий в блокчейн — хватает**, для полноценного клиентского чтения — **пока не хватает**.
Что есть:
- единый надёжный write-путь `AddBlock`;
- есть `GetFriendsLists` и API по `UserParam`;
- есть унифицированные коды ошибок и поля для ресинхронизации.
Что пока ограничивает продукт:
- нет полноценного read API для каналов/постов/тредов;
- нет API списка подписок с серверными счётчиками непрочитанного;
- нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC.
## 8. Рекомендации по клиенту при записи блоков
1. Перед отправкой держать локальный `lastNumber/lastHash`.
2. При `bad_prev_hash` или `bad_block_number`:
- взять `serverLastGlobalNumber/serverLastGlobalHash` из ошибки,
- пересобрать следующий блок на актуальной вершине.
3. Для edit-блоков всегда ссылаться на **оригинальный** блок, а не на предыдущий edit.
4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост.
## 9. USER_PARAM для «личных данных»
Да, на текущем API это можно добавить **без изменения серверного кода**:
- в `UserParam` поле `param` сейчас не ограничено фиксированным справочником;
- сервер хранит пары `param -> value` как строки (при наличии корректной подписи и `time_ms`);
- чтение уже есть через `GetUserParam` и `ListUserParams`.
Рекомендуемый стартовый набор ключей для профиля (MVP):
- `name`
- `last_name`
- `address_physical`
- `address_web`
- `phone`
Практическая рекомендация: заранее зафиксировать единый словарь ключей в клиенте/документации, чтобы избежать дублей вида `lastname` vs `last_name`, `site` vs `address_web` и т.д.
Ограничения, которые важно учесть:
- сейчас нет серверной ACL-политики чтения параметров (в MVP их может читать любой клиент, который знает `login`);
- нет валидации формата значений для конкретных ключей (телефон, URL и т.д. проверяются только на стороне клиента);
- нет отдельного индекса/поиска по этим полям — только точечное чтение и listing по `login`.
---
## 10. `GetBlockchainBlock`
### Назначение
Публичное чтение одного конкретного блока из цепочки.
Нужно для:
- открытого чтения блокчейна по одному блоку;
- межсерверной синхронизации;
- восстановления/докачки отсутствующего хвоста цепочки.
### JSON формат запроса
`op = "GetBlockchainBlock"`.
```json
{
"op": "GetBlockchainBlock",
"requestId": "req-2001",
"payload": {
"blockchainName": "alice-001",
"blockNumber": 12
}
}
```
Поля `payload`:
- `blockchainName` — обязательно, формат `login-NNN`.
- `blockNumber` — обязательно, номер блока в цепочке, `>= 0`.
### Успешный ответ
```json
{
"op": "GetBlockchainBlock",
"requestId": "req-2001",
"status": 200,
"ok": true,
"payload": {
"blockchainName": "alice-001",
"blockNumber": 12,
"blockHash": "9f0eaabbccddeeff00112233445566778899aabbccddeeff0011223344556677",
"blockBytesB64": "AAAB..."
}
}
```
### Ошибки
- `400 / BAD_FIELDS` — некорректные `blockchainName` или `blockNumber`.
- `404 / BLOCK_NOT_FOUND` — такого блока нет.
- `500 / INTERNAL_ERROR` — внутренняя ошибка сервера.
После успешного локального AddBlock полный DataItem хранится в PostgreSQL и ставится в Arweave publish queue. Импорт из Arweave использует ту же проверку, но не ставит блок на повторную публикацию.
File diff suppressed because it is too large Load Diff
-137
View File
@@ -1,137 +0,0 @@
# Карта реализации SHiNE Archive Publisher v1.0
Этот документ связывает протокол с конкретными файлами проекта. Он нужен, чтобы другой агент мог быстро понять, где искать каждую часть реализации.
## 1. Новый Java-модуль `shine-server-archive`
Путь: `SHiNE-server/shine-server-archive/`.
Основные классы:
- `ArchivePublisherScheduler` — включает publisher только при `archive.publish.enabled=true`, сразу продолжает незавершённый job после рестарта и планирует новый snapshot один раз в сутки в `archive.publish.time`. Следующая дата вычисляется по `ZoneId`, а не как `+24h`, поэтому DST не сдвигает локальную полночь.
- `ArchivePublisherService` — state machine: snapshot → локальный файл → Arweave → confirmations → User PDA → Solana finalized → cursor commit.
- `ArchivePublisherConfig` — читает настройки. Для Solana RPC отдельного archive URL нет: используется `solana.users.sync.rpcUrl`, затем fallback на `solana.rpcUrl`.
- `ShineArchiveWriter` — сериализует бинарный big block `SHINE-ARCHIVE v1.0`, FULL reference table, по одному chunk на `blockchain_name`, footer/hash/signature.
- `ArchiveFileNames` — временное и финальное локальные имена.
- `ArweaveArchiveService` + `ArweaveMerkle` — Arweave v2 transaction + chunk upload + polling confirmations.
- `SolanaArchiveHeadWriter` — обычный `update_user_pda`: root signature + текущая last-block signature + client key fee payer, затем ожидание `finalized`.
- `ArchiveKeyLoader` — читает Ed25519 seed/keypair из raw 32/64 bytes, Solana JSON 32/64 или Base64/PKCS8.
## 2. Локальное состояние PostgreSQL
Миграция: `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql`.
Таблицы:
### `archive_chain_cursor`
Одна строка на `blockchain_name`. Хранит только **последнее окончательно опубликованное** состояние:
- последний source block number/hash;
- big block number/hash, где находится последний chunk;
- offset/size последнего chunk.
Если blockchain не попала в новый big block, эта строка не меняется.
### `archive_publish_job`
Crash-safe state одной большой публикации и путь к локальному файлу.
Основные состояния:
`SNAPSHOT_CREATED → FILE_BUILT → ARWEAVE_UPLOADED → ARWEAVE_CONFIRMED → SOLANA_SUBMITTED → SOLANA_FINALIZED → CURSORS_COMMITTED`.
### `archive_publish_job_chain`
Frozen range каждой blockchain текущего job + старые и новые координаты chunk. Пока job не finalized, `archive_chain_cursor` не двигается.
`DatabaseInitializer` автоматически применяет migration v22 при старте существующей БД. Новая БД создаётся уже со схемой v22.
## 3. Как считается первая дельта
`ArchivePublisherService.createFrozenJob()` проходит по `BlockchainStateDAO.listAll()`.
- Если cursor для `blockchain_name` отсутствует: `from = 0`, поэтому первый архив содержит всё локально известное состояние `0..head`.
- Если cursor существует: `from = last_archived + 1`.
- Перед продолжением проверяется hash cursor-блока.
Папка `data/archive` сама по себе **не является источником истины** о том, был ли первый архив. Источник истины — БД cursor/job. Поэтому удаление локального файла не приводит к ошибочной повторной полной публикации.
## 4. Локальный lifecycle файла
До появления Arweave TX ID:
`<login>.<00001>.<dd.MM.yy>.tmp.SHiNE-archive`
После полной успешной загрузки transaction header + chunks в Arweave:
`<login>.<00001>.<dd.MM.yy>.<ARWEAVE_TX_ID>.SHiNE-archive`
Дата — реальная дата freeze snapshot в timezone archive publisher-а. Номер начинается с `00001`. Пять цифр — минимальная ширина, а не лимит.
После rename файл остаётся локально. При crash после сохранения TX ID, но до rename, recovery переименует тот же файл и не загрузит его повторно.
## 5. User PDA block type `100`
Содержимое:
```text
u8 block_type = 100
u8 block_version = 0
bytes[32] archive_tx_id
bytes[32] archive_hash
```
Используется существующий `update_user_pda`; отдельной instruction нет.
Совместимость:
- legacy update без archive extension должен сохранить старый archive head;
- новый update может заменить/очистить block `100`;
- Java/JS codecs и PostgreSQL Solana sync умеют читать новый блок.
Ключевой Rust-файл: `shine-solana/shine/programs/shine_users/src/lib.rs`.
## 6. Startup сервера
`WsServer` после текущего Solana/users sync и inter-server blockchain sync вызывает `ArchivePublisherScheduler.startOrLog()`.
При `archive.publish.enabled=false` scheduler пишет лог о выключенной функции и больше ничего не делает. Ключи/Arweave wallet на обычном сервере тогда не требуются.
## 7. Legacy TestFreeAvatar
Старый временный `TestFreeAvatarArweaveService` больше не является частью активного WS-протокола. Registry/API документация убраны. При наложении changed-files ZIP поверх старого дерева старые исходники физически останутся, поэтому их список для удаления находится в `05_PATCH_CONTENTS_AND_REMOVALS.md`.
## Trusted importer / location index / Viewer
Server importer:
```text
shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java
shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java
shine-server-archive/src/main/java/server/archive/ArchiveImportService.java
shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java
```
Database:
```text
shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java
shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java
shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java
shine-server-db/src/main/resources/postgres/migration_v23.sql
shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java
```
`PostgresStorageRepository` сохраняет `archive_imported=true` при повторном sync того же head и автоматически сбрасывает флаг в `false`, если `archive_head_tx_id` или `archive_head_hash` изменились.
WS API:
```text
GetArchiveBlockchainLocation
```
UI:
```text
shine-UI/js/pages/blockchain-archive-view.js
shine-UI/Blockchain-Viewer.html
```
`Blockchain-Viewer.html` получает `tx + offset + size + blockchain`, идёт назад по `PreviousBlockchainChunkRef` и использует существующий parser каналов старого Viewer-а.
-466
View File
@@ -1,466 +0,0 @@
# Деплой SHiNE Archive Publisher v1.0 на тестовый сервер
Документ рассчитан на человека или автономного coding/deploy агента. Выполнять шаги по порядку. Не включать publisher до проверки Solana-программы и ключей.
## 0. Что именно меняется
Нужны изменения одновременно в:
1. серверном Java-коде;
2. PostgreSQL schema v23;
3. Solana-программе `shine_users` (PDA block type `100` + backward-compatible update parser);
4. Java/JS User PDA codecs.
**Критично:** новый серверный writer отправляет расширенный обычный `update_user_pda`. Если в целевом кластере работает старая `shine_users`, включать publisher нельзя.
---
# 1. Применить пакет к исходникам
ZIP из этой поставки содержит только новые/изменённые файлы с путями относительно корня репозитория.
Сделать backup текущего проекта, затем распаковать ZIP поверх рабочего дерева.
После распаковки удалить legacy-файлы из списка `05_PATCH_CONTENTS_AND_REMOVALS.md`.
Проверить:
```bash
git status --short
```
или, если это не git checkout, сравнить список файлов с manifest из той же документации.
---
# 2. Обязательно обновить `shine_users` в нужном Solana-кластере
## 2.1. Проверить целевой кластер
Не деплоить вслепую. Сначала:
```bash
solana config get
```
и проверить RPC/кластер, upgrade authority и Program ID. В проекте `shine_users` использует Program ID:
```text
SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6
```
Если тестовый сервер использует mainnet RPC, обновляется именно mainnet-программа. Если тестовый контур использует devnet — сначала убедиться, что программа с нужным ID действительно существует в devnet.
## 2.2. Собрать Solana program
```bash
cd shine-solana/shine
anchor build
```
Минимальная host-проверка Rust, если Anchor/SBF toolchain временно недоступен:
```bash
cargo build -p shine_users
```
Но для реального deploy нужен SBF/Anchor build.
## 2.3. Обновить существующую программу
Использовать существующий project deploy/upgrade authority. Типовой вариант:
```bash
solana program deploy target/deploy/shine_users.so \
--program-id target/deploy/shine_users-keypair.json \
--upgrade-authority /PATH/TO/UPGRADE_AUTHORITY.json \
--url <TARGET_RPC_URL>
```
Если в проекте используется рабочий Anchor deploy workflow, допустимо использовать его вместо прямого `solana program deploy`; главное — сохранить тот же Program ID.
После обновления:
```bash
solana program show SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6 --url <TARGET_RPC_URL>
```
---
# 3. Собрать серверный JAR
Из корня репозитория:
```bash
./gradlew clean shadowJar
```
Ожидаемый файл:
```text
SHiNE-server/build/libs/shine-server.jar
```
Если Gradle wrapper не может скачать зависимости, сборку выполнять на машине/CI с доступом к Maven/Gradle или с уже заполненным cache.
---
# 4. Подготовить PostgreSQL backup
Перед первым стартом версии со schema v23 сделать backup тестовой БД. Например:
```bash
pg_dump -Fc -d '<DATABASE_URL_OR_NAME>' -f shine-before-archive-v22.dump
```
Точная команда зависит от текущей схемы доступа PostgreSQL.
При старте сервер последовательно применит `migration_v22.sql` и `migration_v23.sql`, если это требуется текущей версии БД. Вручную migrations выполнять обычно не нужно.
После старта проверить:
```sql
SELECT * FROM db_schema_version WHERE id=1;
```
Ожидается:
```text
schema_version = 23
```
И наличие:
```sql
SELECT to_regclass('public.archive_chain_cursor');
SELECT to_regclass('public.archive_publish_job');
SELECT to_regclass('public.archive_publish_job_chain');
```
---
# 5. Подготовить секреты на тестовом сервере
Пример:
```bash
sudo -u player mkdir -p /home/player/SHiNE/secrets
sudo chmod 700 /home/player/SHiNE/secrets
```
Положить:
```text
/home/player/SHiNE/secrets/archive-arweave-wallet.json
/home/player/SHiNE/secrets/server-root.key
/home/player/SHiNE/secrets/server-client.key
```
Права:
```bash
sudo chown player:player /home/player/SHiNE/secrets/*
sudo chmod 600 /home/player/SHiNE/secrets/*
```
### Форматы root/client key
Поддерживаются:
- raw seed 32 bytes;
- raw keypair 64 bytes;
- Solana JSON array на 32/64 байта;
- Base58 seed на 32 байта или Solana secret key на 64 байта;
- Base64 raw/PKCS8, где seed извлекается из последних 32 bytes.
### Arweave wallet
Ожидается RSA JWK с полями `n,e,d,p,q,dp,dq,qi`. Кошелёк должен иметь достаточно AR для размера первого полного архива.
---
# 6. Настроить внешний `application.properties`
Сервер читает внешний `application.properties` из **WorkingDirectory процесса** и накладывает его поверх встроенного конфига. Сохранять существующие DB/Solana/server параметры и добавить archive-секцию.
Минимум:
```properties
archive.publish.enabled=true
archive.publish.time=00:00
archive.publish.zoneId=Europe/Warsaw
archive.workDir=data/archive
archive.maxFileBytes=4000000000
archive.arweave.gateway=https://arweave.net
archive.arweave.walletJwkPath=/home/player/SHiNE/secrets/archive-arweave-wallet.json
archive.arweave.minConfirmations=1
archive.arweave.confirmPollSeconds=30
archive.arweave.confirmTimeoutMinutes=180
archive.solana.rootKeyPath=/home/player/SHiNE/secrets/server-root.key
archive.solana.clientKeyPath=/home/player/SHiNE/secrets/server-client.key
archive.solana.confirmPollSeconds=5
archive.solana.confirmTimeoutMinutes=30
archive.solana.commitment=finalized
```
`archive.publish.zoneId` выбрать осознанно. Если оставить пустым, используется timezone JVM/машины. Для ежедневного запуска ровно в нужную локальную полночь лучше задать ZoneId явно.
### Solana RPC
**Отдельного archive RPC нет.** Writer использует:
1. `solana.users.sync.rpcUrl`, если он задан;
2. иначе `solana.rpcUrl`.
Поэтому существующий рабочий RPC не дублировать в archive settings.
---
# 7. Убедиться, что server login соответствует ключам
`server.SHiNE.login` должен быть тем User PDA, чей archive head будет обновляться.
На startup `SolanaArchiveHeadWriter` проверяет:
```text
derive(root private) == UserPDA.root_key
derive(client private) == UserPDA.client_key
```
При несовпадении archive publisher не стартует.
---
# 8. Развернуть JAR
Можно использовать существующий `deploy/scripts/deploy_server.sh`. Он:
- собирает `shadowJar`;
- копирует JAR;
- создаёт/обновляет systemd unit;
- перезапускает сервис.
Типовой запуск задаётся уже существующими переменными проекта. Либо вручную заменить `shine-server.jar` в рабочей директории и перезапустить systemd service.
После deploy убедиться, что WorkingDirectory содержит внешний `application.properties`.
---
# 9. Первый startup
Смотреть лог:
```bash
sudo journalctl -u <SERVICE_NAME> -f
```
Ожидаемые события:
1. DB migration до v22;
2. обычный Solana users sync;
3. обычный server-to-server blockchain sync;
4. archive publisher preflight;
5. строка примерно:
```text
Archive publisher включён: login=... dir=data/archive dailyAt=00:00 zone=...
Следующая архивная публикация запланирована на ...
```
Если остался незавершённый job, он будет продолжен **сразу после старта**, не ожидая полуночи. Новый snapshot создаётся только по расписанию.
---
# 10. Что произойдёт в первую полночь
Если `archive_chain_cursor` пуст:
- сервер проходит все локально известные `blockchain_name`;
- для каждой берёт range `0..current_head`;
- создаёт первый big block `#1`;
- для каждой blockchain создаёт максимум один `UserBlockchainChunk`;
- внутри chunk лежат все её raw records из frozen range;
- backlink первого chunk пустой (`previous_big_block_ref = 0xFFFFFFFF`);
- создаёт локальный файл, например:
```text
data/archive/archive01.00001.12.09.26.tmp.SHiNE-archive
```
- загружает его в Arweave;
- после успешной загрузки переименовывает тот же файл, например:
```text
data/archive/archive01.00001.12.09.26.<REAL_TX_ID>.SHiNE-archive
```
- ждёт confirmations;
- обычным `update_user_pda` записывает block type `100`;
- ждёт Solana `finalized`;
- только затем commit-ит cursors.
Если новых данных нет, пустой big block не создаётся.
---
# 11. Проверка результата
## Локальные файлы
```bash
ls -lah data/archive/
```
После успешного upload `.tmp.SHiNE-archive` для завершённого job оставаться не должен; должен быть файл с реальным TX ID в имени.
## Job DB
```sql
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
arweave_confirmations, solana_signature, error_text
FROM archive_publish_job
ORDER BY id DESC
LIMIT 10;
```
Успех:
```text
status = CURSORS_COMMITTED
```
## Cursors
```sql
SELECT blockchain_name,
last_archived_source_block_number,
last_archive_big_block_number,
last_chunk_offset,
last_chunk_size
FROM archive_chain_cursor
ORDER BY blockchain_name;
```
## Локальная проекция User PDA
После очередной Solana sync:
```sql
SELECT login, record_number, archive_head_tx_id, archive_head_hash
FROM solana_user_pda_current
WHERE login = '<SERVER_LOGIN>';
```
`archive_head_tx_id` и `archive_head_hash` должны быть непустыми.
## Arweave
TX берётся прямо из имени финального файла. Проверить:
```bash
curl -sS 'https://arweave.net/tx/<TX_ID>/status'
```
---
# 12. Быстрый тест до полуночи
Если не хочется ждать 00:00, на тестовом сервере временно установить `archive.publish.time` на ближайшие 5–10 минут в будущем в выбранной `archive.publish.zoneId`, затем перезапустить сервис.
После проверки вернуть:
```properties
archive.publish.time=00:00
```
Не использовать интервал в минутах: scheduler специально работает по календарному локальному времени один раз в сутки.
---
# 13. Откат / выключение
Самый безопасный функциональный rollback:
```properties
archive.publish.enabled=false
```
и рестарт сервера. Тогда обычная серверная работа продолжается, archive scheduler ничего не публикует.
Не удалять `archive_chain_cursor`/job таблицы без причины: они нужны, чтобы после повторного включения publisher продолжил дельту, а не загрузил всю историю заново.
Уже опубликованные Arweave данные являются постоянными и обычным rollback сервера не удаляются.
---
# 14. Наиболее вероятные ошибки
### `Root key archive publisher-а не совпадает с User PDA`
Положен неправильный root key или неверный `server.SHiNE.login`.
### `Client key archive publisher-а не совпадает с User PDA`
Неверный client key.
### `Недостаточно AR`
Пополнить Arweave wallet. Первый архив может быть существенно больше ежедневных дельт.
### Arweave upload прошёл, Solana update не прошёл
Не удалять локальный файл/job. После исправления RPC/program/key причины restart продолжит незавершённый job.
### `archive cursor hash не совпадает`
Локальная blockchain изменилась относительно уже зафиксированного cursor. Не форсировать публикацию; сначала разобраться с resync/fork.
### Старый `shine_users`
Если новый update payload отклоняется программой, проверить, что целевая Solana `shine_users` действительно обновлена этой версией.
## Trusted archive importer
На обычном тестовом сервере publisher можно оставить выключенным, но разрешить импорт от конкретного архиватора:
```properties
archive.publish.enabled=false
archive.import.allowedPublishers=<LOGIN_ARCHIVE_SERVER>
archive.import.intervalMinutes=60
archive.import.workDir=data/archive-import
```
Несколько логинов:
```properties
archive.import.allowedPublishers=server-a,server-b
```
Пустая строка означает, что importer не запускается.
После старта проверить логи:
```text
Archive importer включён. approvedPublishers=...
```
Если сервер подключается к publisher впервые, importer скачает head, прочитает FULL reference table и обработает все ещё не известные big blocks от старых к новым.
Проверка БД:
```sql
SELECT login, archive_head_tx_id, archive_imported, archive_last_imported_tx_id
FROM solana_user_pda_current
WHERE is_server=TRUE AND archive_head_tx_id<>''
ORDER BY login;
SELECT blockchain_name, publisher_login, arweave_tx_id,
big_block_number, chunk_offset, chunk_size, source_last_block_number
FROM archive_blockchain_location
ORDER BY updated_at_ms DESC
LIMIT 20;
```
После деплоя UI файл должен быть доступен по:
```text
https://<UI_HOST>/Blockchain-Viewer.html
```
В приложении: `Настройки → Архив блокчейна`.
-113
View File
@@ -1,113 +0,0 @@
# Проверка и эксплуатация Archive Publisher
## Перед ночным тестом
- [ ] Новый `shine_users` уже развёрнут на том Solana-кластере, который использует сервер.
- [ ] Серверный JAR собран из этого пакета.
- [ ] БД забэкаплена.
- [ ] `archive.publish.enabled=true`.
- [ ] `archive.publish.time=00:00`.
- [ ] `archive.publish.zoneId` соответствует желаемой локальной полуночи.
- [ ] `server.SHiNE.login` соответствует root/client keys.
- [ ] Arweave JWK читается пользователем процесса.
- [ ] На Arweave wallet достаточно AR.
- [ ] `data/archive` доступна на запись.
- [ ] В логе есть `Archive publisher включён` и точное время следующего запуска.
## Во время job
Нормальная последовательность логов/статусов:
```text
SNAPSHOT_CREATED
FILE_BUILT
ARWEAVE_UPLOADED
ARWEAVE_CONFIRMED
SOLANA_SUBMITTED
SOLANA_FINALIZED
CURSORS_COMMITTED
```
После `FILE_BUILT` существует `.tmp.SHiNE-archive`.
После полного Arweave upload имя уже содержит настоящий TX ID.
## После успешного первого job
Проверить:
```bash
find data/archive -maxdepth 1 -type f -name '*.SHiNE-archive' -ls
```
```sql
SELECT big_block_number, status, local_archive_path, arweave_confirmations
FROM archive_publish_job ORDER BY id DESC LIMIT 1;
```
```sql
SELECT count(*) AS archived_blockchains FROM archive_chain_cursor;
```
```sql
SELECT login, archive_head_tx_id, archive_head_hash
FROM solana_user_pda_current
WHERE login='<SERVER_LOGIN>';
```
## Проверка второй публикации
До следующей полуночи добавить несколько новых SHiNE records только в часть blockchain. После следующего job:
- в новый big block должны попасть только изменившиеся blockchain;
- одна blockchain в новом big block должна иметь один chunk независимо от числа новых records;
- cursor blockchain, которая не изменилась, должен остаться на старом big block/chunk;
- backlink изменившегося chunk должен указывать на предыдущий chunk этой же blockchain;
- FULL reference table нового big block должна содержать все предыдущие finalized big blocks.
## Crash/restart сценарии
### Restart после FILE_BUILT
Должен использоваться тот же frozen job и тот же локальный файл.
### Restart после ARWEAVE_UPLOADED
Не должно быть повторной оплаты/upload. Если TX сохранён, но rename не успел произойти, recovery переименует `.tmp` в имя с TX ID.
### Restart после SOLANA_FINALIZED
При совпадении PDA head с job сервер должен только commit cursors.
## Обычный сервер без публикации
Проверить отдельно:
```properties
archive.publish.enabled=false
```
Сервер должен запускаться без Arweave/root/client archive key files и не создавать `archive_publish_job`.
## Проверка trusted importer
1. На принимающем сервере указать только тестовый publisher:
```properties
archive.import.allowedPublishers=<publisher-login>
```
2. Перезапустить сервер.
3. Дождаться Solana PDA sync и цикла importer-а.
4. Проверить, что у publisher в `solana_user_pda_current` после успешного цикла `archive_imported=true`, а `archive_last_imported_tx_id=archive_head_tx_id`.
5. Проверить `archive_blockchain_location`.
6. Для blockchain, которой локально не хватало блоков, убедиться, что `blockchain_state.last_block_number` вырос.
7. Для уже существующих блоков importer должен пропускать совпадающий hash, а не создавать дубликат.
8. Временно удалить publisher из whitelist и убедиться, что новые archive heads больше не скачиваются.
### Проверка Viewer
Открыть `Настройки → Архив блокчейна`, получить ссылку и проверить:
- `tx`, `offset`, `size`, `blockchain` присутствуют;
- Viewer собирает несколько chunks по backlink;
- неправильный `blockchain` в URL приводит к ошибке проверки;
- `channel` открывает нужный канал;
- `message` прокручивает к нужному block number.
@@ -1,38 +0,0 @@
# Состав текущего patch-пакета
Этот этап рассчитан **поверх последнего рабочего ZIP**, присланного после успешного запуска archive publisher.
Пакет этого этапа добавляет:
- trusted archive importer;
- whitelist publisher-ов через настройки сервера;
- строгую проверку больших `SHINE-ARCHIVE`;
- импорт недостающих raw SHiNE blocks через обычный validator `AddBlock`;
- локальные поля состояния импорта в `solana_user_pda_current` и таблицу `archive_blockchain_location`;
- schema migration v23;
- WS API `GetArchiveBlockchainLocation`;
- экран `Настройки → Архив блокчейна`;
- `shine-UI/Blockchain-Viewer.html`;
- документацию importer/viewer.
## Удаления
В **этом** обновлении удалять файлы не требуется.
Старые test/free-avatar исходники, если они всё ещё физически присутствуют в рабочем дереве, этим patch-пакетом не затрагиваются. Они не относятся к trusted archive importer и не должны удаляться автоматически при наложении этого обновления.
## Как накладывать ZIP changed-files
ZIP содержит только новые/изменённые файлы с путями от корня репозитория.
Распаковать поверх той рабочей версии, из которой сделан пакет, с заменой совпадающих файлов.
После наложения:
```bash
./gradlew shadowJar
```
Если Gradle wrapper ещё не установлен локально, сначала обеспечить доступ к уже используемой версии Gradle/кэшу.
При старте сервер сам должен поднять schema с v22 до v23.
-50
View File
@@ -1,50 +0,0 @@
# Manifest changed/new files
Основа сравнения: последний присланный рабочий ZIP `3d14e34c-4249-4e11-8042-3f3349c8e9fd.zip`.
- Изменённых файлов: 19
- Новых файлов: 14
- Удаляемых файлов: 0
## Изменённые файлы
- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java`
- `SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java`
- `SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql`
- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java`
- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java`
- `SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java`
- `SHiNE-server/src/main/java/server/ws/WsServer.java`
- `SHiNE-server/src/main/resources/application.properties`
- `docs/Archive/01_PROTOCOL_v1.0.md`
- `docs/Archive/02_IMPLEMENTATION_MAP.md`
- `docs/Archive/03_DEPLOY_TEST_SERVER.md`
- `docs/Archive/04_TEST_AND_OPERATIONS.md`
- `docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md`
- `docs/Archive/06_FILE_MANIFEST.md`
- `docs/Archive/README.md`
- `docs/Archive/archive-publisher.example.properties`
- `shine-UI/js/app.js`
- `shine-UI/js/pages/settings-view.js`
- `shine-UI/js/services/auth-service.js`
## Новые файлы
- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java`
- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java`
- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java`
- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java`
- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java`
- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java`
- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java`
- `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql`
- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java`
- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java`
- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java`
- `docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md`
- `shine-UI/Blockchain-Viewer.html`
- `shine-UI/js/pages/blockchain-archive-view.js`
## Удаления
На этом этапе файлов для удаления нет.
@@ -1,303 +0,0 @@
# Импорт доверенных SHINE-ARCHIVE и Blockchain Viewer
Этот документ описывает вторую половину архивной системы: как обычный SHiNE-сервер узнаёт о новых archive head других серверов, кому доверяет, как импортирует недостающие SHiNE-блоки и как UI получает ссылку на историю конкретной `blockchain_name`.
## 1. Источник archive head
Archive importer **не делает отдельные Solana RPC-запросы**.
Уже существующий Solana Users Sync разбирает User PDA block type `100` и копирует его в локальную таблицу:
```text
solana_user_pda_current.archive_head_tx_id
solana_user_pda_current.archive_head_hash
```
Для локального состояния importer v23 добавляет туда же:
```text
archive_imported BOOLEAN
archive_last_imported_tx_id TEXT
```
Это локальные поля сервера, в Solana они не записываются.
Когда обычный Solana Users Sync видит тот же archive head повторно, `archive_imported` сохраняется как есть.
Когда `archive_head_tx_id` или `archive_head_hash` изменился:
```text
archive_imported = false
```
а `archive_last_imported_tx_id` сохраняет последнюю успешно обработанную точку и позволяет продолжить после сбоя.
## 2. Whitelist доверенных publisher-ов
В `application.properties` задаётся список логинов серверов, архивы которых разрешено принимать:
```properties
archive.import.allowedPublishers=archive-server-1,archive-server-2
archive.import.intervalMinutes=60
archive.import.workDir=data/archive-import
```
Правила:
- логины разделяются запятыми;
- сравнение без учёта регистра;
- пустое `archive.import.allowedPublishers=` полностью выключает importer;
- принимаются только строки `solana_user_pda_current` с `is_server=true`;
- whitelist является только первым фильтром, криптографические проверки всё равно обязательны.
## 3. Периодическая проверка
После запуска сервера importer делает первую проверку примерно через 10 секунд, затем по умолчанию раз в 60 минут.
Каждый цикл — дешёвый запрос только к локальной PostgreSQL:
```text
approved publisher
AND is_server=true
AND archive_head_tx_id != ''
AND archive_imported=false
```
Если таких строк нет, Arweave не вызывается.
## 4. Проверки archive block
Для каждого pending publisher сервер проверяет:
1. publisher находится в whitelist;
2. `archive_head_tx_id/archive_head_hash` уже пришли через обычный User PDA sync;
3. SHA-256 скачанного файла совпадает с `archive_head_hash`;
4. `creator_login == closer_login == publisher login`;
5. Ed25519 archive signature проверяется root key publisher-а из User PDA;
6. каждый вложенный raw SHiNE block проходит обычную SHiNE-проверку через существующий `AddBlock` path.
Подпись большого архива защищает контейнер и навигацию, а подписи обычных SHiNE blocks защищают сами пользовательские данные.
## 5. Как определяется, что head новый
Отдельная таблица обработанных TX для основной логики не нужна.
Текущий User PDA snapshot уже содержит:
```text
archive_head_tx_id
archive_head_hash
archive_imported
archive_last_imported_tx_id
```
Пример:
```text
archive_head_tx_id = TX100
archive_imported = false
archive_last_imported_tx_id = TX97
```
Это означает: Solana уже объявила `TX100` текущей головой publisher-а, но локальный сервер успел импортировать только до `TX97`.
После полной успешной обработки `TX100`:
```text
archive_imported = true
archive_last_imported_tx_id = TX100
```
При следующем новом PDA head Users Sync сам сбросит `archive_imported=false`.
## 6. Догон пропущенных больших блоков
Если сервер был выключен и вместо `TX97` сразу увидел `TX100`, он скачивает и проверяет `TX100`, читает его FULL reference table и находит `TX97`.
После этого импортирует только:
```text
TX98
TX99
TX100
```
После каждого полностью импортированного большого блока `archive_last_imported_tx_id` сдвигается вперёд.
Если сервер впервые видит publisher и `archive_last_imported_tx_id` пустой, импортируются все previous refs от старых к новым, затем текущий head.
Если непустой `archive_last_imported_tx_id` отсутствует в FULL history текущего head, importer останавливается: это рассматривается как возможная смена/fork archive chain, а не как повод молча забыть старый cursor.
## 7. Crash recovery
Если процесс упал после `TX98`, но до `TX100`:
```text
archive_imported = false
archive_last_imported_tx_id = TX98
```
Следующий часовой цикл продолжит с `TX99`.
Если процесс успел импортировать head и записать `archive_last_imported_tx_id = TX100`, но упал до установки `archive_imported=true`, следующий цикл просто завершит отметку без повторной загрузки всей цепочки.
Все cursor updates выполняются условно по ожидаемому `archive_head_tx_id`. Если обычный Solana Users Sync успел заменить head во время импорта, старый процесс не сможет пометить новый head импортированным.
## 8. Импорт `UserBlockchainChunk`
Для каждого chunk:
1. берётся lock этой `blockchain_name`;
2. если локального `blockchain_state` нет, identity создаётся по синхронизированному User PDA;
3. raw records разбираются как обычные `BchBlockEntry`;
4. уже существующий block допускается только при совпадении hash;
5. новый block должен идти строго `localLast + 1`;
6. новый block добавляется существующим validator/write path;
7. конфликт hash или gap останавливает импорт этой archive chain.
## 9. Индекс последнего archive chunk
Таблица:
```text
archive_blockchain_location
```
содержит для каждой `blockchain_name`:
```text
blockchain_name
publisher_login
arweave_tx_id
archive_hash
big_block_number
chunk_offset
chunk_size
source_last_block_number
updated_at_ms
```
Если blockchain встретилась в новом archive block, её location обновляется. Если не встретилась — старая ссылка остаётся.
Эту таблицу заполняют как trusted importer, так и локальный archive publisher.
## 10. API для UI
WS operation:
```text
GetArchiveBlockchainLocation
```
Request:
```json
{
"op": "GetArchiveBlockchainLocation",
"blockchainName": "alice-001"
}
```
Response содержит:
```text
blockchainName
publisherLogin
arweaveTxId
archiveHash
bigBlockNumber
chunkOffset
chunkSize
sourceLastBlockNumber
```
## 11. UI и ссылка Viewer
В настройках пользователя есть экран `Архив блокчейна`.
Viewer-файл:
```text
shine-UI/Blockchain-Viewer.html
```
Основные параметры ссылки:
```text
/Blockchain-Viewer.html
?tx=<ARWEAVE_TX_ID>
&offset=<CHUNK_OFFSET>
&size=<CHUNK_SIZE>
&blockchain=<BLOCKCHAIN_NAME>
```
Дополнительно:
```text
&channel=<CHANNEL_NAME>
&message=<BLOCK_NUMBER>
```
`blockchain` используется также для проверки: если загруженный chunk имеет другое имя blockchain, Viewer прекращает обработку.
`channel` открывает нужный канал, а `message` прокручивает к указанному сообщению/block number и выделяет его.
## 12. Как Viewer собирает всю цепочку
Viewer начинает с последнего `TX + offset + size`:
```text
последний UserBlockchainChunk
PreviousBlockchainChunkRef
FULL reference table текущего big block
TX предыдущего big block
Range предыдущего chunk
следующий backlink
до NO_REFERENCE
```
Чужие chunks скачивать не требуется.
## 13. Минимальная настройка принимающего сервера
```properties
archive.publish.enabled=false
archive.import.allowedPublishers=server-a,server-b
archive.import.intervalMinutes=60
archive.import.workDir=data/archive-import
```
Если импорт архивов не нужен:
```properties
archive.import.allowedPublishers=
```
Тогда importer вообще не запускается.
## 14. Диагностика PostgreSQL
Pending archive heads:
```sql
SELECT login, archive_head_tx_id, archive_imported, archive_last_imported_tx_id
FROM solana_user_pda_current
WHERE is_server = TRUE
AND archive_head_tx_id <> ''
ORDER BY login;
```
Последние известные пользовательские chunks:
```sql
SELECT blockchain_name, publisher_login, arweave_tx_id,
big_block_number, chunk_offset, chunk_size, source_last_block_number
FROM archive_blockchain_location
ORDER BY updated_at_ms DESC;
```
-42
View File
@@ -1,42 +0,0 @@
# SHiNE Archive Publisher — документация
Эта папка — **актуальная точка входа** для механизма серверной архивации SHiNE в Arweave с фиксацией archive head в Solana User PDA.
Если задачу выполняет другая нейронка/агент, читать документы нужно в таком порядке:
1. `01_PROTOCOL_v1.0.md` — бинарный формат `SHINE-ARCHIVE`, big-block references, `UserBlockchainChunk`, подписи, PDA block `100`, crash-safety.
2. `02_IMPLEMENTATION_MAP.md` — как спецификация разложена по Java/Rust/JS/SQL файлам текущего проекта.
3. `03_DEPLOY_TEST_SERVER.md` — полный порядок установки на тестовый сервер, включая обязательный апгрейд `shine_users`, конфиг, ключи, сборку и запуск.
4. `04_TEST_AND_OPERATIONS.md` — что проверять до полуночи, после полуночи и при сбоях.
5. `05_PATCH_CONTENTS_AND_REMOVALS.md` — какие файлы содержит пакет и какие legacy test-free-avatar файлы нужно удалить при наложении ZIP поверх старого исходника.
6. `07_ARCHIVE_IMPORT_AND_VIEWER.md` — whitelist доверенных publisher-ов, импорт archive chain, индекс последнего chunk, UI и `Blockchain-Viewer.html`.
7. `archive-publisher.example.properties` — пример конфигурации publisher + importer.
## Коротко
- Архиватор **по умолчанию выключен**: `archive.publish.enabled=false`.
- При включении создаёт новый snapshot **один раз в сутки в заданное локальное время**, по умолчанию `00:00`.
- При первом успешном запуске, когда архивных курсоров ещё нет, в первый big block попадает **всё локально известное состояние всех blockchain, начиная с source block 0**.
- Далее публикуется только дельта.
- Один `blockchain_name` в одном big block представлен максимум одним `UserBlockchainChunk`; внутри него лежат все новые raw SHiNE records этой цепочки.
- В конце chunk одна ссылка на предыдущий chunk этой же blockchain. Если blockchain в текущем big block отсутствует, её cursor/head не меняется.
- Готовый файл сначала существует локально как `<login>.<00001>.<dd.MM.yy>.tmp.SHiNE-archive`. После успешной загрузки в Arweave он переименовывается в `<login>.<00001>.<dd.MM.yy>.<REAL_ARWEAVE_TX_ID>.SHiNE-archive` и остаётся локально.
- После Arweave confirmations обычным `update_user_pda` обновляется PDA block type `100`: `archive_tx_id[32] + archive_hash[32]`.
- Cursor commit выполняется только после Solana `finalized`.
## Важно перед тестом
Изменён формат/парсер `shine_users`. **Нельзя просто заменить серверный JAR и включить archive publisher, если целевая Solana-программа `shine_users` ещё не обновлена кодом из этого пакета.** Сначала обновить программу на нужном кластере, затем сервер.
## Импорт архивов других серверов
Импорт по умолчанию также выключен. Настройка:
```properties
archive.import.allowedPublishers=
```
Пустой список означает: не доверять архивам ни одного внешнего сервера. Для разрешения перечислить логины через запятую. Подробности — `07_ARCHIVE_IMPORT_AND_VIEWER.md`.
Текущая схема БД: **v23**. `v22` добавила publisher, `v23` добавляет локальные `archive_imported/archive_last_imported_tx_id`, trusted importer и универсальный `archive_blockchain_location`.
@@ -1,33 +0,0 @@
# Минимальный пример для archive-capable сервера.
# Добавлять во внешний application.properties; существующие DB/Solana/server настройки не удалять.
archive.publish.enabled=true
archive.publish.time=00:00
archive.publish.zoneId=Europe/Warsaw
archive.workDir=data/archive
archive.maxFileBytes=4000000000
archive.arweave.gateway=https://arweave.net
archive.arweave.walletJwkPath=/home/player/SHiNE/secrets/archive-arweave-wallet.json
archive.arweave.minConfirmations=1
archive.arweave.confirmPollSeconds=30
archive.arweave.confirmTimeoutMinutes=180
# Отдельный archive Solana RPC НЕ задаётся.
# Используется solana.users.sync.rpcUrl, иначе solana.rpcUrl.
archive.solana.rootKeyPath=/home/player/SHiNE/secrets/server-root.key
archive.solana.clientKeyPath=/home/player/SHiNE/secrets/server-client.key
# Эти файлы могут содержать Base58 seed 32 bytes или Base58 Solana secret key 64 bytes.
archive.solana.confirmPollSeconds=5
archive.solana.confirmTimeoutMinutes=30
archive.solana.commitment=finalized
# =============================================================
# Trusted archive import (independent from publisher)
# Empty = do not import archives from any external server.
# Comma-separated SHiNE server logins, case-insensitive.
# =============================================================
archive.import.allowedPublishers=
archive.import.intervalMinutes=60
archive.import.workDir=data/archive-import
+102 -35
View File
@@ -1,49 +1,116 @@
# Общий формат добавляемого блока (Frame v0)
# Общий формат пользовательского блока SHiNE — Frame v1 / ANS-104
Этот файл описывает **единый бинарный формат** блока, который клиент отправляет через `AddBlock` в поле `blockBytesB64`.
Актуальный формат не поддерживает старый Frame v0. Новый пользовательский блок сразу создаётся как готовый подписанный ANS-104 DataItem.
## 1. Полная структура блока
## 1. Два уровня формата
Блок состоит из двух частей:
Полный объект, который клиент отправляет в `AddBlock`, хранится в PostgreSQL и затем архивируется в Arweave:
1. **PREIMAGE** (подписывается)
2. **TAIL** (маркер подписи + подпись)
```text
ANS-104 DataItem
signature_type = 2 (generic Ed25519)
signature = 64 bytes
owner = 32-byte blockchain public key
target = absent
anchor = absent
tags
data = SHiNE Frame v1
```
### PREIMAGE
Цепочка SHiNE **не зависит от Arweave**. Arweave DataItem ID хранится отдельно и используется для дедупликации/поиска, но не является `prevHash`.
- `frameCode (uint16)`
- `prevHash32 (32 bytes)`
- `blockSize (int32)` — размер PREIMAGE
- `blockNumber (int32)`
- `timestamp (int64)`
- `type (uint16)`
- `subType (uint16)`
- `version (uint16)`
- `bodyBytes (N)`
## 2. SHiNE Frame v1
### TAIL
Все целые поля Frame v1 — BigEndian.
- `sigMarker (uint16)`
- `signature64 (64 bytes, Ed25519)`
| Поле | Размер | Описание |
|---|---:|---|
| `frameCode` | 2 | `0x0001` |
| `prevHash32` | 32 | SHA-256 полного Frame v1 предыдущего SHiNE-блока; для блока 0 — нули |
| `blockSize` | 4 | точный размер Frame v1, включая header и body |
| `blockNumber` | 4 | номер блока, начиная с 0 |
| `timestamp` | 8 | Unix time seconds |
| `type` | 2 | тип сообщения |
| `subType` | 2 | подтип |
| `version` | 2 | версия body |
| `body` | N | данные конкретного типа |
## 2. Что проверяет сервер при AddBlock
`FRAME_HEADER_SIZE = 56` bytes.
- `frameCode` должен быть `0x0000`.
- `sigMarker` должен быть `0x0100`.
- `blockNumber` должен идти строго по порядку (`last + 1`).
- `prevHash32` должен совпасть с вершиной цепочки на сервере.
- `body` должен пройти `check()` для конкретного типа.
- подпись должна валидироваться публичным ключом блокчейна.
```text
blockHash32 = SHA256(FrameV1Bytes)
next.prevHash32 = blockHash32
```
## 3. Ограничения
Подписи внутри Frame v1 нет. Единственная подпись пользователя — подпись окружающего ANS-104 DataItem.
- максимальный полный размер блока: до 4 MiB;
- timestamp не должен сильно уходить в будущее;
- `bodyBytes` парсится по `type/subType/version` из заголовка блока.
## 3. ANS-104 DataItem
## 4. Почему это важно
Для тестового контура используется generic Ed25519 signature type `2`: подпись 64 bytes, owner 32 bytes. Это отдельный generic Ed25519 signer type; Solana-specific signer в текущем Turbo/arbundles имеет другой type.
Одинаковый общий формат позволяет:
- передавать разные виды записей через один RPC `AddBlock`;
- валидировать блоки единообразно;
- расширять типы `body`, не ломая каркас блока.
Подписывается стандартный ANS-104 deep-hash:
```text
[
"dataitem",
"1",
"2",
owner,
target(empty),
anchor(empty),
rawAvroTags,
FrameV1Bytes
]
```
`rawAvroTags` — ровно те Avro-serialized bytes тегов, которые лежат внутри DataItem.
`dataItemId32 = SHA256(signature64)`.
### Обязательный тестовый тег
Каждый блок:
```text
App = test5590
```
Это временное namespace-значение для разработки. Перед реальным запуском оно будет заменено отдельным изменением протокола/кода.
### Канальный тег
Если блок относится к конкретному каналу, он дополнительно содержит:
```text
c = <canonical_channel_slug>
```
Slug входит в подпись DataItem и не может быть изменён сервером после подписи.
## 4. Что хранится в PostgreSQL
`blocks.block_bytes` содержит **полный serialized ANS-104 DataItem**, а не только Frame.
Отдельно индексируются:
- `block_hash` — SHA-256(Frame v1);
- `block_signature` — 64-byte Ed25519 signature из DataItem;
- `data_item_id` — SHA-256(signature), UNIQUE;
- `block_number`, `bch_name`, message fields;
- состояние публикации в Arweave.
Локальные `.bch`, `.tmp_bch` и marker-файлы для пользовательских blockchain больше не используются.
## 5. Проверка AddBlock
Сервер обязан:
1. распарсить полный ANS-104 DataItem;
2. проверить `App=test5590`;
3. проверить `c`, если тип блока требует канал;
4. проверить ANS-104 Ed25519 подпись;
5. проверить, что `owner` равен текущему blockchain public key пользователя;
6. распарсить Frame v1 и body;
7. проверить `blockNumber == last + 1`;
8. проверить `prevHash32 == lastBlockHash`;
9. записать DataItem и новое состояние атомарно в PostgreSQL.
@@ -0,0 +1,97 @@
# ANS-104 / Arweave transport для пользовательских блоков SHiNE
## Цель
Каждый пользовательский блок уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер не переподписывает пользовательский контент: он проверяет его, хранит в PostgreSQL и объединяет готовые DataItems в стандартный ANS-104 bundle.
## Child DataItem tags
Обязательно для тестового контура:
```text
App=test5590
```
Дополнительно для блоков конкретного канала:
```text
c=<canonical_channel_slug>
```
Теги входят в ANS-104 подпись пользователя.
## Publisher
По умолчанию цикл — раз в 15 минут.
```text
blocks.arweave_publish_pending=true
готовые serialized DataItems
ANS-104 binary bundle
обычная Arweave L1 transaction
```
Если pending-блоков нет, транзакция не создаётся.
Root transaction содержит стандартные bundle tags:
```text
Bundle-Format=binary
Bundle-Version=2.0.0
Content-Type=application/octet-stream
App=test5590-batch
```
`App=test5590-batch` намеренно отличается от child `App=test5590`, чтобы discovery-запрос находил пользовательские блоки, а не root bundles.
После успешной L1-загрузки сервер ставит child-блокам:
- `arweave_publish_pending=false`;
- `arweave_published_at_ms`;
- `arweave_root_tx_id`.
## Importer
Каждый сервер может независимо искать:
```text
App=test5590
```
через GraphQL gateway с cursor pagination.
Для каждого нового DataItem:
1. взять `id` и `bundledIn.id`;
2. получить root bundle;
3. извлечь точные serialized bytes child DataItem по bundle index;
4. проверить `dataItemId == SHA256(signature)`;
5. проверить ANS-104 Ed25519 подпись;
6. определить пользователя по `owner`;
7. применить обычные проверки `AddBlock`;
8. записать в PostgreSQL с `arweave_publish_pending=false`.
### Блоки могут прийти не по порядку
Discovery/import использует persistent queue `arweave_block_import_queue`. Если, например, block 102 увиден раньше block 101, block 102 остаётся `PENDING`; после появления 101 очередь повторно проигрывается.
## Дедупликация и несколько серверов
Один и тот же готовый DataItem имеет один `data_item_id = SHA256(signature)`. Если несколько серверов включили его в разные root bundles, локально это всё равно один логический блок: `blocks.data_item_id` уникален.
Импортированный из Arweave блок **не ставится обратно в publish queue**. Это предотвращает бесконечное переархивирование между серверами.
## Локальное хранение
Пользовательские blockchain-файлы на диске больше не используются. Полный serialized DataItem находится в `blocks.block_bytes` PostgreSQL.
## Настройки
См. `application.properties` и `CODEX_APPLY_ANS104_TEST5590_PATCH.md`.
## Что намеренно не входит в этот патч
Remote/homeserver signing path, связанный с внешним homeserver/ESP32 signer, не мигрируется этим патчем. Каталог `ESP32/` не изменяется. До отдельной миграции новый Frame v1/ANS-104 production path рассчитан на клиент, у которого локально доступен blockchain Ed25519 key.
+13
View File
@@ -236,3 +236,16 @@
- Добавлена поддержка командного префикса `/.` и команды `/.desc` для актуализации описания канала при чтении.
- Зафиксированы команды `/.add` и `/.remove` для каналов `type=200` (зарезервировано под расширение участниками).
- В `AGENTS.md` добавлено обязательное правило актуализации документации в `docs/Blockchain/`.
## 2026-09-20 — Frame v1 + ANS-104 DataItem / Arweave per-block transport
- Основание: пользователь явно подтвердил breaking change и запуск с чистой историей; patch archive, commit hash ещё отсутствует.
- Удалена поддержка старого Frame v0 (`preimage + sigMarker + signature`).
- Новый пользовательский block — SHiNE Frame v1 внутри стандартного ANS-104 DataItem, generic Ed25519 signature type 2.
- Signing message синхронизирован с фактическим arbundles/Turbo DataItem: включает строку signature type и raw Avro tag bytes.
- SHiNE chain hash остаётся независимым: `SHA256(FrameV1Bytes)`; Arweave DataItem ID хранится отдельно.
- В тестовом контуре каждый DataItem подписывает tag `App=test5590`; channel blocks дополнительно `c=<canonical slug>`.
- PostgreSQL стал единственным локальным хранилищем пользовательских block bytes; `.bch/.tmp_bch/marker` storage/recovery удалён.
- Старый custom `SHINE-ARCHIVE` pipeline удалён. Добавлены standard ANS-104 bundle publisher через существующий Arweave L1 uploader и GraphQL importer.
- Импортированные из Arweave blocks не публикуются повторно; дедупликация идёт по `data_item_id`.
- ESP32 и связанный device code этим изменением не затронуты.
@@ -0,0 +1,108 @@
# Инструкция Codex: применить ANS-104 test5590 patch
## Цель
Перевести пользовательский blockchain SHiNE на Frame v1 внутри готовых ANS-104 DataItems и убрать старый SHINE-ARCHIVE/файловое хранение цепочек.
## Жёсткое ограничение
**Не изменять ничего в `ESP32/`.** В этом patch нет ни одного файла `ESP32/**`.
Remote/homeserver signer, завязанный на устройство, намеренно не мигрирован. Не пытаться «заодно исправить» его в рамках этого patch.
## Применение
1. Распаковать patch поверх корня репозитория, сохраняя относительные пути.
2. Удалить все пути из корневого `DELETE_FILES.txt`.
3. Проверить, что `git diff -- ESP32` пуст.
4. Использовать чистую/dev test DB. `migration_v24.sql` намеренно откажется мигрировать непустую blockchain DB, потому что backward compatibility со старым block format не требуется.
## Arweave config
Минимально для публикации:
```properties
arweave.blocks.publish.enabled=true
arweave.blocks.publish.intervalMinutes=15
arweave.blocks.publish.gateway=https://arweave.net
arweave.blocks.publish.walletJwkPath=/ABSOLUTE/SECRET/PATH/arweave-wallet.json
```
JWK не коммитить.
Для discovery/import:
```properties
arweave.blocks.sync.enabled=true
arweave.blocks.sync.intervalMinutes=15
arweave.blocks.sync.gateway=https://turbo-gateway.com
arweave.blocks.sync.startBlockHeight=0
```
На тестах желательно установить `startBlockHeight` на высоту начала `test5590`, чтобы не сканировать лишнюю историю.
## Test namespace
Child DataItem:
```text
App=test5590
```
Channel child:
```text
App=test5590
c=<canonical_channel_slug>
```
Root bundle:
```text
Bundle-Format=binary
Bundle-Version=2.0.0
App=test5590-batch
```
Перед production-start test namespace должен быть заменён отдельным осознанным изменением.
## Проверки после применения
Из корня репозитория:
```bash
node --check shine-UI/js/services/ans104-data-item.js
node --check shine-UI/js/services/auth-service.js
node --check shine-UI/js/app.js
node --check shine-UI/js/pages/settings-view.js
```
Java/Gradle:
```bash
./gradlew testClasses
./gradlew test
```
Затем локальный smoke test по штатной инструкции проекта, например `./gradlew startLocal`.
В среде, где готовился patch, Gradle wrapper не смог скачать Gradle 8.14 из-за отсутствия внешнего сетевого доступа к `services.gradle.org`. Поэтому полный Gradle compile/test обязательно прогнать после применения в обычной dev-среде.
## Smoke scenario
1. Создать/использовать тестового пользователя с локальным blockchain Ed25519 key.
2. Добавить обычный block и убедиться, что `blocks.block_bytes` начинается с ANS-104 DataItem, а `data_item_id` заполнен.
3. Создать channel и post; проверить `c=<canonical slug>`.
4. Включить publisher, дождаться цикла или вызвать сервис тестом; проверить root Arweave tx.
5. На второй чистой test DB включить importer и убедиться, что `App=test5590` blocks восстанавливаются в правильном порядке.
6. Убедиться, что imported blocks имеют `arweave_publish_pending=false`.
7. Проверить, что повторный discovery не создаёт дублей.
## Не делать в этом patch
- не добавлять backward compatibility Frame v0;
- не возвращать `.bch` storage;
- не возвращать SHINE-ARCHIVE;
- не менять ESP32;
- не мигрировать remote/homeserver signing без отдельного решения пользователя;
- не заменять `prevHash` на Arweave DataItem ID.
+34 -41
View File
@@ -1,47 +1,40 @@
# Документация блокчейна SHiNE (MVP)
# SHiNE Blockchain
Этот каталог описывает только текущий рабочий формат протокола для MVP.
Актуальная версия пользовательского blockchain использует **SHiNE Frame v1 внутри подписанного ANS-104 DataItem**. Старый Frame v0 и файловое хранение `.bch` не поддерживаются.
## Основные документы
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
Единый бинарный формат блока (Frame v0), подпись, базовые проверки.
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
Виды цепочек и правила line-полей.
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
Системные блоки (`msg_type=0`).
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
Текстовые блоки (`msg_type=1`).
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
Реакции (`msg_type=2`).
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
Социальные связи (`msg_type=3`).
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
Параметры пользователя (`msg_type=4`).
8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md)
Статусные действия пользователя (`msg_type=5`).
9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md)
Вложения в TEXT-сообщениях через `S:att v=1`, включая опциональные `preAr/preSha256` для видео и крупных изображений.
10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md)
Скрытый `TEXT_CHANNEL_META` для профиля канала.
11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
Типы каналов и формат `CreateChannelBody`.
12. [02_Channel_Commands.md](./02_Channel_Commands.md)
Команды в текстовых сообщениях каналов.
13. [CHANGELOG.md](./CHANGELOG.md)
Журнал изменений документации.
## Смежная документация
- [../ИТХ/README.md](../ИТХ/README.md) — ежедневное закрытие блокчейна (ИТХ): краткий обзор.
- [../ИТХ/Спецификация_ИТХ_v1.md](../ИТХ/Спецификация_ИТХ_v1.md) — точная спецификация чекпоинтов (Arweave/Solana/канал закрытий).
- [sync-between-servers.md](./sync-between-servers.md) — живая межсерверная синхронизация блокчейна и доставка DM на единственный сервер получателя.
- [`01_Common_Block_Format.md`](01_Common_Block_Format.md) — точный Frame v1, ANS-104, подпись, хэши и теги.
- [`17_ANS104_Arweave_Transport.md`](17_ANS104_Arweave_Transport.md) — публикация блоков в Arweave и обратный импорт между серверами.
- [`sync-between-servers.md`](sync-between-servers.md) — межсерверная синхронизация и full-resync без файловой копии blockchain.
- [`CHANGELOG.md`](CHANGELOG.md) — история изменений протокола.
## Важные ограничения MVP
- Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI.
- Поддерживаемый рабочий сценарий UI на текущем этапе: `stories (type=0)` и `public (type=1)`.
## Короткая схема
## Обязательное сопровождение
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
- Обычный `AddBlock` сейчас пишет через `<blockchainName>.tmp_bch`, `<blockchainName>.write_check` и `<blockchainName>.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery.
- Для runtime-агрегатов статистики `user_stats_state` и `channel_stats_state` действует тот же принцип derived state: они обновляются вместе с `AddBlock` и полностью пересобираются при full resync.
- Если в старых данных есть канал владельца, которого ещё нет в `solana_user_pda_current`, сервер не падает: `channel_stats_state` всё равно обновляется, а `user_stats_state` создаётся только после появления пользователя в Solana PDA.
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
```text
User
-> создаёт Frame v1
-> tags: App=test5590, при канале c=<slug>
-> Ed25519 подписывает ANS-104 deep-hash
-> готовый DataItem
-> AddBlock
Server
-> verify DataItem + SHiNE chain
-> PostgreSQL
-> каждые ~15 минут ANS-104 bundle
-> Arweave L1
Other servers
-> GraphQL App=test5590
-> скачивают/извлекают DataItem
-> verify
-> PostgreSQL без повторной публикации
```
## Важные свойства
- SHiNE `prevHash` остаётся SHA-256 предыдущего Frame v1 и не зависит от Arweave.
- `data_item_id` нужен для Arweave, поиска и дедупликации.
- PostgreSQL — единственное локальное хранилище пользовательских блоков.
- Тестовый namespace `App=test5590` специально отделён от будущего production namespace.
+25 -263
View File
@@ -1,280 +1,42 @@
# Синхронизация блокчейнов и доставка DM между серверами SHiNE
# Синхронизация blockchain между SHiNE-серверами
Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE.
## Локальная модель хранения
## 1. Зачем нужна синхронизация
PostgreSQL является единственным локальным хранилищем пользовательских блоков. Файлы `<blockchain>.bch`, `.tmp_bch`, `.write_pending`, `.write_check`, `.resync_pending` не используются.
Пользователи SHiNE могут быть «приписаны» к разным серверам.
Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y):
`blocks.block_bytes` хранит полный подписанный ANS-104 DataItem. `blockchain_state` хранит текущую вершину цепочки.
1. Сервер X принимает сообщение;
2. Сервер X должен переслать DM-блок серверу Y;
3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B.
## Обычный AddBlock
Аналогично, блоки пользовательского блокчейна (записи `AddBlock`) должны синхронизироваться,
чтобы любой партнёрский сервер мог отдать полную историю пользователя.
Все проверки и запись выполняются под lock конкретной chain. После проверки Frame v1, подписи и `prevHash` одна SQL-транзакция записывает block + derived state + новую вершину.
## 2. Список серверов синхронизации (`sync_servers`)
Локально созданный пользовательский блок получает `arweave_publish_pending=true`.
Каждый сервер регистрирует в своей Solana PDA список `sync_servers`
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
## Периодический peer-to-peer sync
`sync_servers` относится к серверному узлу и не является списком
access-серверов обычного пользователя. У пользователя действует только первый
`access_servers[0]`.
Старый межсерверный P2P sync может получать `GetBlockchainBlock` и применять полученный полный DataItem через `AddBlock`. При divergence full-resync:
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
1. берёт lock chain;
2. очищает derived rows/blocks/state через `BlockchainResyncCleanupDAO`;
3. пересоздаёт state из актуального пользовательского реестра;
4. последовательно проигрывает удалённую цепочку с блока 0;
5. никаких файловых swap/recovery операций нет.
## 3. Что синхронизируется
## Arweave sync
### 3.1 Личные сообщения (DM)
Дополнительно каждый сервер может независимо включить `ArweaveBlockSyncScheduler`.
- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt).
- Сервер-отправитель: сохраняет пару и ставит асинхронную delivery-задачу.
- Сервер-получатель: сохраняет входящий блок в `signed_messages`, затем доставляет его активным сессиям.
- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`.
- Между access-серверами одного пользователя DM не реплицируются.
- Полная актуальная схема: `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`.
Он ищет child DataItems по тестовому тегу `App=test5590`, проверяет их и импортирует через ту же бизнес-проверку AddBlock. Импортированные блоки не публикуются повторно.
### 3.2 Блоки пользовательского блокчейна
Если блоки обнаружены не по порядку, persistent queue оставляет более поздние блоки pending до появления предыдущих.
- Все блоки `AddBlock` пользователей, зарегистрированных на сервере или синхронизирующихся через него.
- Синхронизируются в обе стороны между всеми партнёрами из `sync_servers`.
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
- Дедупликация по глобальному номеру блока и хэшу.
## Источник истины для цепочки
### 3.3 Пользовательские настройки
Arweave не участвует в вычислении SHiNE chain hash:
Пользовательские настройки хранятся локально на единственном access-сервере и
между серверами не синхронизируются.
```text
block_hash = SHA256(FrameV1Bytes)
next.prevHash32 = block_hash
```
## 4. Текущая реализованная схема
На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов.
### 4.1 Что уже сделано
1. При старте сервер читает свой `server.SHiNE.login`.
2. По этому логину он загружает из Solana свою server PDA.
3. Из неё вытаскивает список `sync_servers`.
4. Для каждого логина партнёра сервер читает его PDA и сохраняет локально:
- `login`
- `server_address`
5. После этого:
- новые локальные `AddBlock` рассылаются партнёрам в фоне;
- при старте запускается periodic sync;
- periodic sync повторяется каждые `12` часов после старта.
### 4.2 Какие server-to-server API уже используются
- `ListBlockchainHeads` — список heads всех локальных цепочек партнёра;
- `GetBlockchainBlock` — чтение одного конкретного блока партнёра;
- `GetSyncUserProfile` — минимальный профиль пользователя для локального создания runtime-проекции пользователя и `blockchain_state` без обращения в Solana RPC.
### 4.3 Как сейчас работает periodic sync
Для каждого сервера из локальной таблицы `sync_servers`:
1. запрашивается `ListBlockchainHeads`;
2. для каждой удалённой цепочки сравниваются:
- `lastBlockNumber`
- `lastBlockHash`
- локальное состояние;
3. если локальная цепочка слабее, сервер по одному блоку вызывает `GetBlockchainBlock`;
4. каждый скачанный блок локально применяется через существующий `AddBlock`;
5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальная runtime-проекция пользователя и `blockchain_state`.
6. если во время replay обнаруживается рассинхрон или на одинаковой высоте удалённая цепочка сильнее, запускается полный resync:
- цепочка помечается in-memory как `resync in progress`;
- создаётся marker-file в `data/`;
- в одной SQL-транзакции очищаются локальные данные цепочки и корректируются чужие счётчики;
- удаляются `.bch` и `.tmp_bch`;
- цепочка подтягивается заново с `0` через `GetBlockchainBlock`.
- обычный `AddBlock` на эту цепочку в этот момент возвращает `chain_resync_in_progress`.
### 4.4 Как именно работает full resync
Full resync запускается только тогда, когда:
- локальная chain отстаёт и обычная докачка хвоста упирается в `bad_prev_hash` или `bad_block_number`;
- либо высота цепочек одинаковая, но удалённая версия сильнее по правилу:
- `lastBlockNumber`;
- `fileSizeBytes`;
- `lastBlockHash`.
Порядок действий:
1. Ставится in-memory guard на `blockchainName`.
2. Создаётся marker-file `<blockchainName>.resync_pending`.
3. Обычный `AddBlock` на эту chain временно получает `chain_resync_in_progress`.
4. Вызывается атомарный SQL cleanup одной chain:
- уменьшаются чужие `likes_count` и `replies_count`;
- удаляются локальные derived-state записи этой chain;
- удаляются `blocks` и `blockchain_state` этой chain.
5. Удаляются файлы `<blockchainName>.bch` и `<blockchainName>.tmp_bch`.
6. Локальная chain создаётся заново через `GetSyncUserProfile` или через Solana import, если `sync.importUserProfileFromPartner.enabled=false`.
7. Chain replay-ится с `0` через `GetBlockchainBlock`.
8. Если всё прошло успешно, marker-file удаляется.
9. Если на любом шаге произошёл сбой, marker-file остаётся на диске, и сервер добивает эту chain при следующем старте.
Важно:
- full resync не делает умный rollback по одному блоку;
- full resync не трогает DM-таблицы и current users слой;
- висячие cross-chain ссылки считаются допустимым поведением системы.
### 4.5 Как работает обычный `AddBlock` и его recovery
Обычная запись блока теперь тоже идёт через временные артефакты:
1. собирается `<blockchainName>.tmp_bch` как полный кандидат на замену основного файла;
2. пишется маленький sidecar `<blockchainName>.write_check` с `blockNumber` и `blockHash`;
3. только после этого создаётся пустой marker `<blockchainName>.write_pending`;
4. выполняется SQL-транзакция;
5. после `commit` tmp атомарно ставится на место основного `.bch`;
6. marker и sidecar удаляются.
На старте `BlockchainTmpRecoveryOnStartup` смотрит именно на эту пару:
- если `write_pending` есть, recovery проверяет sidecar и БД, а затем либо завершает swap, либо чистит временные файлы;
- если `write_pending` нет, а `tmp_bch` или `write_check` остались, это мусор и он удаляется;
- `resync_pending` сюда не относится, это отдельный recovery-поток.
### 4.6 Startup recovery по marker-file
При старте сервер идёт в таком порядке:
1. `BlockchainTmpRecoveryOnStartup` для `*.write_pending` и orphan `*.tmp_bch` / `*.write_check`;
2. `BlockchainResyncRecoveryOnStartup` для `*.resync_pending`;
3. только потом поднимается обычный сервер и запускается `PeriodicBlockchainSyncService`.
Если marker-file существует:
- сервер не должен начинать обычную работу поверх этой chain;
- recovery снова выполняет cleanup и replay с нуля;
- если recovery не завершился, marker остаётся, и сервер не переходит к обычному режиму для этой chain.
### 4.7 Зачем понадобился `GetSyncUserProfile`
Изначально подготовка локальной цепочки делалась через Solana:
- из `blockchainName` извлекался `login`;
- сервер вызывал import пользователя из Solana PDA;
- по данным PDA локально создавались runtime-проекция пользователя и `blockchain_state`.
На практике это упёрлось в ограничение внешнего Solana RPC: при чистом старте и массовой подтяжке чужих цепочек сервер мог получать `HTTP 429`.
Поэтому добавлен отдельный обходной режим:
- настройка `sync.importUserProfileFromPartner.enabled=true`
- в этом режиме сервер **не ходит в Solana RPC** для создания локальной цепочки во время sync;
- вместо этого он запрашивает у сервера-партнёра `GetSyncUserProfile` и создаёт локальную запись по данным партнёра.
- если локальная runtime-проекция пользователя уже существует, sync восстанавливает только `blockchain_state` и не трогает user-layer.
Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint.
### 4.8 Что делает настройка `sync.importUserProfileFromPartner.enabled`
- `false` — стандартный режим, подготовка локального пользователя идёт через Solana PDA;
- `true` — sync-режим обхода Solana, локальный пользователь создаётся по server-to-server `GetSyncUserProfile`.
Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.
## 5. Реализованный постоянный server-to-server транспорт
Этот раздел не меняет текущую семантику DM, settings и blockchain. Он описывает
единый постоянный WSS-транспорт, через который выполняются уже существующие операции.
### 5.1 Межсерверное соединение
- Серверы устанавливают постоянное исходящее WebSocket-соединение друг с другом.
- Адрес партнёра определяется по `server_address` из его Solana PDA.
- После подключения отправляется `ServerHello` с `serverLogin`, версией протокола и capabilities.
- На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена.
- При разрыве выполняется переподключение с jitter/backoff до 60 секунд.
- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд.
- Один физический канал переиспользуют доставка DM и blockchain.
### 5.2 Доставка новых данных (push)
- При получении нового блока сервер может немедленно пушить его всем подключённым партнёрам.
- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
- DM использует отдельное расписание, описанное в `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`.
### 5.3 Начальная синхронизация (backfill)
- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов.
- Сервер с более полной историей досылает недостающее партнёру.
- DM-history backfill между access-серверами отсутствует.
### 5.4 Разрешение конфликтов
- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока.
Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `docs/Blockchain/README.md`).
- DM: конфликтов нет, `message_key` уникален.
## 6. Маршрутизация DM между серверами
При отправке DM от пользователя A к пользователю B:
1. Клиент A отправляет пару блоков на свой сервер X.
2. Сервер X валидирует и локально сохраняет пару.
3. До ответа клиенту X отправляет входящую копию на единственный
`access_servers[0]` пользователя B через `ReceiveIncomingMessage`.
4. Успешное сохранение на сервере B означает терминальный `delivered`.
5. При неудаче выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час.
6. После неудачной попытки через час ставится терминальный `failed`.
Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке.
## 7. Безопасность
- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое.
- Серверы не расшифровывают DM-контент; E2EE уже выполняется клиентами.
- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере.
- Межсерверная авторизация DM-операций пока отложена; `sourceServerLogin` временно считается доверенным.
## 8. Статус реализации
| Компонент | Статус |
|-----------|--------|
| Регистрация серверной PDA в Solana | ✅ Реализовано |
| Чтение `sync_servers` из PDA | ✅ Реализовано |
| Локальная таблица `sync_servers` | ✅ Реализовано |
| Публичный `ListBlockchainHeads` | ✅ Реализовано |
| Публичный `GetBlockchainBlock` | ✅ Реализовано |
| Публичный `GetSyncUserProfile` | ✅ Реализовано |
| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано |
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` |
| Асинхронная доставка DM на единственный access-сервер получателя | ✅ Реализовано |
| Retry DM до 1 часа + UI-state | ✅ Реализовано |
| Репликация DM и настроек между access-серверами | Не используется |
| Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул |
| Periodic backfill отсутствующего хвоста | ✅ Реализовано |
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано |
| Маршрутизация DM только через `access_servers[0]` | ✅ Реализовано |
| Криптографическая server-to-server авторизация DM | Нужна реализация |
Текущая версия сервера использует постоянный WSS-пул для существующих
server-to-server JSON-операций. Отдельной будущей задачей остаётся
криптографическая авторизация server-to-server вызовов.
Следующие отдельные шаги после текущего этапа:
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
### 8.1 Практическая проверка на тестовом сервере
Проверка на `server2.shineup.me` показала, что текущая схема действительно поднимает цепочку при старте:
- после рестарта сервер сначала проходит `BlockchainTmpRecovery`;
- затем обрабатывает `BlockchainResyncRecovery`;
- после этого сам догружает цепочку `aidartest-001` с `shineup.me`;
- итоговое состояние на тестовом сервере:
- `blockchain_state.last_block_number = 13`
- `blocks` по `aidartest-001` = `14` записей
Это подтверждает, что startup sync и full-resync flow работают в живом сценарии, а не только в коде.
Поэтому цепочка остаётся проверяемой и переносимой независимо от конкретного storage backend.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -94,7 +94,7 @@ UserPdaRecordV1
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. |
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `100` | `ArchiveHeadBlock` | Текущая голова серверного SHINE-ARCHIVE: Arweave TX ID + SHA-256 архива. |
| `100` | `ArchiveHeadBlock` | Legacy/reserved. Новый per-block ANS-104 transport не использует это поле для пользовательской истории. |
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
Правила:
@@ -374,7 +374,7 @@ ArchiveHeadBlock
Семантика:
- `archive_tx_id`raw 32-byte Arweave transaction id последнего опубликованного большого `SHINE-ARCHIVE`; текстовая Base64URL-форма получается вне PDA;
- `archive_tx_id`legacy/reserved поле старой archive-head схемы; новый per-block ANS-104 transport его не обновляет;
- `archive_hash` — SHA-256 большого archive block по правилам `docs/Archive/01_PROTOCOL_v1.0.md`;
- отсутствие block `100` означает, что аккаунт ещё не объявлял archive head;
- обычный legacy `update_user_pda`, в instruction которого archive extension отсутствует, **обязан сохранить существующий ArchiveHeadBlock без изменений**;
@@ -136,7 +136,7 @@
- upgrade-authority программы после проверки передается DAO;
- пользовательские операции `create_user_pda` и `update_user_pda` остаются доступными обычным пользователям при корректных подписях и оплате.
## ArchiveHeadBlock и серверный SHINE-ARCHIVE
## ArchiveHeadBlock (legacy/reserved)
Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`):
@@ -145,7 +145,7 @@ archive_tx_id [32]
archive_hash [32]
```
Он хранит текущую голову архива конкретного SHiNE-аккаунта: raw Arweave TX ID и SHA-256 соответствующего большого `SHINE-ARCHIVE`. Подробный бинарный формат и серверный workflow находятся в `docs/Archive/01_PROTOCOL_v1.0.md`.
Поле `ArchiveHeadBlock` осталось в Solana/PDA как legacy/reserved для совместимости формата PDA. Новый transport пользовательских блоков не использует server-level SHINE-ARCHIVE или archive head: каждый пользовательский block публикуется как ANS-104 DataItem внутри стандартных bundles.
Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим:
+1 -2
View File
@@ -1,4 +1,4 @@
shine-server-blockchain — это библиотека, которая задаёт формат блока, правила парсинга/валидации тела, крипто-проверку (hash+Ed25519) и безопасную работу с файлами блокчейна (data/<name>.bch через временный .tmp_bch).
shine-server-blockchain задаёт Frame v1, ANS-104 DataItem, правила парсинга/валидации body и криптографическую проверку. Пользовательские chain-файлы на диске больше не используются; полные DataItems хранятся в PostgreSQL.
Как устроена структура и логика работы
@@ -33,7 +33,6 @@ BchCryptoVerifier отвечает за “как получить хэш и к
4) Утилиты вокруг имени и файлов
BlockchainNameUtil — извлекает login из blockchainName (отрезает 3 символа суффикса).
FileStoreUtil — безопасное файловое хранилище:
5) Объяснение структуры работы
+30
View File
@@ -0,0 +1,30 @@
-proc:none
-d
/tmp/javac-check
-sourcepath
SHiNE-server/shine-server-geo/src/main/java:SHiNE-server/shine-server-log/src/main/java:SHiNE-server/shine-server-solana-users-sync/src/main/java:SHiNE-server/shine-server-db/src/main/java:SHiNE-server/src/main/java:SHiNE-server/shine-server-archive/src/main/java:SHiNE-server/shine-server-blockchain/src/main/java:SHiNE-server/shine-server-crypto/src/main/java:SHiNE-server/shine-server-net-protocol/src/main/java:SHiNE-server/shine-server-config/src/main/java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java
/mnt/data/shine_patch_final_work/SHiNE-server/src/main/java/server/ws/WsServer.java
/mnt/data/shine_patch_final_work/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java
/mnt/data/shine_patch_final_work/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java
File diff suppressed because one or more lines are too long
-2
View File
@@ -73,7 +73,6 @@ import * as profileEditView from './pages/profile-edit-view.js';
import * as profilesView from './pages/profiles-view.js';
import * as walletView from './pages/wallet-view.js?v=202606281930';
import * as settingsView from './pages/settings-view.js';
import * as blockchainArchiveView from './pages/blockchain-archive-view.js';
import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js';
import * as advancedSettingsView from './pages/advanced-settings-view.js';
@@ -142,7 +141,6 @@ const routes = {
'profiles-view': profilesView,
'wallet-view': walletView,
'settings-view': settingsView,
'blockchain-archive-view': blockchainArchiveView,
'access-servers-view': accessServersView,
'developer-settings-view': developerSettingsView,
'advanced-settings-view': advancedSettingsView,
@@ -1,144 +0,0 @@
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
export const pageMeta = { id: 'blockchain-archive-view', title: 'Архив блокчейна' };
function text(value) {
return String(value == null ? '' : value).trim();
}
function buildViewerUrl(location, blockchainName, channelName, messageNumber) {
const url = new URL('/Blockchain-Viewer.html', window.location.origin);
url.searchParams.set('tx', text(location.arweaveTxId));
url.searchParams.set('offset', String(location.chunkOffset));
url.searchParams.set('size', String(location.chunkSize));
url.searchParams.set('blockchain', blockchainName);
const channel = text(channelName);
if (channel) url.searchParams.set('channel', channel);
const message = text(messageNumber);
if (message) url.searchParams.set('message', message);
return url.toString();
}
async function copyText(value) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
const area = document.createElement('textarea');
area.value = value;
document.body.appendChild(area);
area.select();
document.execCommand('copy');
area.remove();
}
export function render({ navigate, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
title: 'Архив блокчейна',
back: { label: '←', onClick: () => navigate('settings-view') },
}));
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<div class="stack" style="gap:.35rem;">
<strong>Последний архивный блок</strong>
<div id="archive-status" class="meta-muted">Загружаю...</div>
</div>
<label class="field">
<span>Блокчейн</span>
<input id="archive-blockchain" type="text" readonly>
</label>
<label class="field">
<span>Канал (необязательно)</span>
<input id="archive-channel" type="text" placeholder="Название канала">
</label>
<label class="field">
<span>Номер сообщения / блока (необязательно)</span>
<input id="archive-message" type="number" min="0" step="1" placeholder="Например, 125">
</label>
<label class="field">
<span>Ссылка</span>
<textarea id="archive-link" rows="5" readonly style="resize:vertical;"></textarea>
</label>
<div style="display:flex; gap:.6rem; flex-wrap:wrap;">
<button id="archive-copy" class="shine-btn" type="button" disabled>Копировать ссылку</button>
<button id="archive-open" class="shine-btn" type="button" disabled>Открыть Viewer</button>
</div>
<details>
<summary>Технические данные</summary>
<pre id="archive-tech" style="white-space:pre-wrap; word-break:break-all;"></pre>
</details>
`;
screen.appendChild(card);
const status = card.querySelector('#archive-status');
const blockchainInput = card.querySelector('#archive-blockchain');
const channelInput = card.querySelector('#archive-channel');
const messageInput = card.querySelector('#archive-message');
const linkInput = card.querySelector('#archive-link');
const copyButton = card.querySelector('#archive-copy');
const openButton = card.querySelector('#archive-open');
const tech = card.querySelector('#archive-tech');
let location = null;
let blockchainName = '';
function refreshLink() {
if (!location || !blockchainName) {
linkInput.value = '';
copyButton.disabled = true;
openButton.disabled = true;
return;
}
linkInput.value = buildViewerUrl(location, blockchainName, channelInput.value, messageInput.value);
copyButton.disabled = false;
openButton.disabled = false;
}
channelInput.addEventListener('input', refreshLink);
messageInput.addEventListener('input', refreshLink);
copyButton.addEventListener('click', async () => {
if (!linkInput.value) return;
await copyText(linkInput.value);
status.textContent = 'Ссылка скопирована.';
});
openButton.addEventListener('click', () => {
if (linkInput.value) window.open(linkInput.value, '_blank', 'noopener,noreferrer');
});
void (async () => {
try {
const login = text(state.session.login);
if (!login) throw new Error('Нет активного пользователя');
const user = await authService.getUser(login);
blockchainName = text(user.blockchainName);
if (!blockchainName) throw new Error('У пользователя не найден blockchainName');
blockchainInput.value = blockchainName;
location = await authService.getArchiveBlockchainLocation(blockchainName);
if (!text(location.arweaveTxId) || !Number.isFinite(Number(location.chunkOffset)) || !Number.isFinite(Number(location.chunkSize))) {
throw new Error('Сервер вернул неполную архивную ссылку');
}
status.textContent = `Известна архивная история до блока ${location.sourceLastBlockNumber ?? '—'}.`;
tech.textContent = [
`publisher: ${text(location.publisherLogin) || '—'}`,
`archive big block: ${location.bigBlockNumber ?? '—'}`,
`Arweave TX: ${text(location.arweaveTxId)}`,
`archive hash: ${text(location.archiveHash) || '—'}`,
`chunk offset: ${location.chunkOffset}`,
`chunk size: ${location.chunkSize}`,
`source last block: ${location.sourceLastBlockNumber ?? '—'}`,
].join('\n');
refreshLink();
} catch (error) {
status.textContent = `Архивная ссылка пока недоступна: ${error?.message || error}`;
tech.textContent = '';
refreshLink();
}
})();
return screen;
}
-7
View File
@@ -71,12 +71,6 @@ export function render({navigate, chrome}) {
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
</span>
</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-blockchain-archive">
<span style="display:block; text-align:left;">
<strong>Архив блокчейна</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Ссылка на архивную историю вашего публичного блокчейна</span>
</span>
</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-arweave-uploads">
<span style="display:block; text-align:left;">
<strong>Загрузить файлы в блокчейн</strong>
@@ -91,7 +85,6 @@ export function render({navigate, chrome}) {
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-blockchain-archive').addEventListener('click', () => navigate('blockchain-archive-view'));
card.querySelector('#settings-arweave-uploads').addEventListener('click', () => navigate('arweave-uploads-view'));
card.querySelector('#settings-language').addEventListener('click', () => {
sessionStorage.setItem('shine-language-return-page', 'settings-view');
+125
View File
@@ -0,0 +1,125 @@
const TE = new TextEncoder();
export const ANS104_ED25519_SIGNATURE_TYPE = 2;
function concatBytes(...chunks) {
const len = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(len);
let p = 0;
for (const c of chunks) { out.set(c, p); p += c.length; }
return out;
}
function uint16LE(value) {
const out = new Uint8Array(2);
new DataView(out.buffer).setUint16(0, Number(value), true);
return out;
}
function uint64LE(value) {
let v = BigInt(value);
if (v < 0n) throw new Error('uint64LE: negative value');
const out = new Uint8Array(8);
for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; }
return out;
}
function encodeAvroLong(value) {
let n = BigInt(value);
// Avro zig-zag signed long.
let u = n >= 0n ? (n << 1n) : ((-n << 1n) - 1n);
const bytes = [];
do {
let b = Number(u & 0x7fn);
u >>= 7n;
if (u !== 0n) b |= 0x80;
bytes.push(b);
} while (u !== 0n);
return Uint8Array.from(bytes);
}
function encodeAvroString(value) {
const bytes = TE.encode(String(value));
return concatBytes(encodeAvroLong(bytes.length), bytes);
}
/** Serialize ANS-104 tags as the Avro array used by arbundles/Turbo. */
export function serializeAns104Tags(tags = []) {
const normalized = tags.map(({ name, value }) => ({
name: String(name ?? ''),
value: String(value ?? ''),
}));
if (normalized.length > 128) throw new Error('ANS-104 supports at most 128 tags');
for (const tag of normalized) {
if (!tag.name || !tag.value) throw new Error('ANS-104 tag name/value must be non-empty');
if (TE.encode(tag.name).length > 1024) throw new Error('ANS-104 tag name exceeds 1024 bytes');
if (TE.encode(tag.value).length > 3072) throw new Error('ANS-104 tag value exceeds 3072 bytes');
}
if (normalized.length === 0) return Uint8Array.of(0);
return concatBytes(
encodeAvroLong(normalized.length),
...normalized.flatMap((tag) => [encodeAvroString(tag.name), encodeAvroString(tag.value)]),
Uint8Array.of(0),
);
}
async function digest(name, bytes) {
return new Uint8Array(await crypto.subtle.digest(name, bytes));
}
async function deepHashBlob(bytes) {
const tag = TE.encode(`blob${bytes.length}`);
return digest('SHA-384', concatBytes(await digest('SHA-384', tag), await digest('SHA-384', bytes)));
}
async function deepHash(item) {
if (item instanceof Uint8Array) return deepHashBlob(item);
if (Array.isArray(item)) {
let acc = await digest('SHA-384', TE.encode(`list${item.length}`));
for (const child of item) {
acc = await digest('SHA-384', concatBytes(acc, await deepHash(child)));
}
return acc;
}
throw new Error('Unsupported ANS-104 deepHash item');
}
/**
* Build the exact signing message used by current arbundles/Turbo DataItems.
* SHiNE intentionally uses no target and no anchor.
*/
export async function ans104SigningMessage({ owner32, tags = [], data }) {
if (!(owner32 instanceof Uint8Array) || owner32.length !== 32) throw new Error('ANS-104 owner must be 32 bytes');
if (!(data instanceof Uint8Array)) throw new Error('ANS-104 data must be Uint8Array');
const rawTags = serializeAns104Tags(tags);
return deepHash([
TE.encode('dataitem'),
TE.encode('1'),
TE.encode(String(ANS104_ED25519_SIGNATURE_TYPE)),
owner32,
new Uint8Array(0),
new Uint8Array(0),
rawTags,
data,
]);
}
/** Create one complete serialized ANS-104 DataItem signed with Ed25519. */
export async function createAns104DataItem({ owner32, privateKey, data, tags = [] }) {
if (!(data instanceof Uint8Array)) throw new Error('ANS-104 data must be Uint8Array');
const rawTags = serializeAns104Tags(tags);
const signingMessage = await ans104SigningMessage({ owner32, tags, data });
const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, signingMessage));
if (signature.length !== 64) throw new Error(`Unexpected Ed25519 signature length: ${signature.length}`);
return concatBytes(
uint16LE(ANS104_ED25519_SIGNATURE_TYPE),
signature,
owner32,
Uint8Array.of(0), // target absent
Uint8Array.of(0), // anchor absent
uint64LE(tags.length),
uint64LE(rawTags.length),
rawTags,
data,
);
}
+41 -22
View File
@@ -37,6 +37,7 @@ import {
saveEncryptedUserSecrets,
saveSessionMaterial,
} from './key-vault.js';
import { createAns104DataItem } from './ans104-data-item.js';
import { defaultServerWs } from '../deploy-config.js';
const BCH_SUFFIX = '001';
@@ -973,7 +974,7 @@ function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType
const blockSize = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + body.length;
return concatBytes(
int16Bytes(0),
int16Bytes(1),
prevHashBytes,
int32Bytes(blockSize),
int32Bytes(blockNumber),
@@ -1077,14 +1078,6 @@ export class AuthService {
return response.payload || {};
}
async getArchiveBlockchainLocation(blockchainName) {
const cleanBlockchainName = String(blockchainName || '').trim();
if (!cleanBlockchainName) throw new Error('Не указано имя блокчейна');
const response = await this.ws.request('GetArchiveBlockchainLocation', { blockchainName: cleanBlockchainName });
if (response.status !== 200) throw opError('GetArchiveBlockchainLocation', response);
return response.payload || response || {};
}
async resolveLoginForAuth(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');
@@ -1645,7 +1638,7 @@ export class AuthService {
};
}
async submitPreparedAddBlock({ login, storagePwd, blockchainName, blockNumber, prevBlockHash, preimage }) {
async submitPreparedAddBlock({ login, storagePwd, blockchainName, blockNumber, prevBlockHash, preimage, ansTags }) {
const cleanLogin = String(login || '').trim();
const cleanBlockchainName = String(blockchainName || '').trim();
const cleanPrevBlockHash = normalizeHex32(prevBlockHash, ZERO_HASH_HEX);
@@ -1658,17 +1651,22 @@ export class AuthService {
const blockchainPrivatePkcs8 = String(savedKeys?.blockchainKey || '').trim();
if (blockchainPrivatePkcs8) {
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
const hash32 = await sha256Bytes(preimage);
const signatureBytes = await signBytes(privateKey, hash32);
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
const owner32 = base64ToBytes(await publicKeyB64FromPkcs8Ed25519(blockchainPrivatePkcs8));
const dataItemBytes = await createAns104DataItem({
owner32,
privateKey,
data: preimage,
tags: Array.isArray(ansTags) && ansTags.length ? ansTags : [{ name: 'App', value: 'test5590' }],
});
return this.ws.request('AddBlock', {
blockchainName: cleanBlockchainName,
blockNumber: Number(blockNumber),
prevBlockHash: cleanPrevBlockHash,
blockBytesB64: bytesToBase64(fullBlock),
blockBytesB64: bytesToBase64(dataItemBytes),
});
}
const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim();
if (!remoteSessionId) {
throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock');
@@ -1782,7 +1780,7 @@ export class AuthService {
};
}
async runAddBlockWithRetry({ login, storagePwd, resolveFreshState, buildPreimage }) {
async runAddBlockWithRetry({ login, storagePwd, resolveFreshState, buildPreimage, ansTags }) {
let freshState = await resolveFreshState();
let blockchainName = String(freshState?.blockchainName || '').trim();
if (!blockchainName) throw new Error('runAddBlockWithRetry: blockchainName is empty');
@@ -1798,6 +1796,7 @@ export class AuthService {
blockNumber,
prevBlockHash,
preimage,
ansTags,
});
};
@@ -1824,7 +1823,7 @@ export class AuthService {
};
}
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes, channelSlug = '' }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login for AddBlock');
if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');
@@ -1844,10 +1843,15 @@ export class AuthService {
return response.payload || {};
}
const ansTags = [{ name: 'App', value: 'test5590' }];
const cleanChannelSlug = String(channelSlug || '').trim();
if (cleanChannelSlug) ansTags.push({ name: 'c', value: cleanChannelSlug });
const { response, blockchainName } = await this.runAddBlockWithRetry({
login: cleanLogin,
storagePwd,
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
ansTags,
buildPreimage: async ({ blockNumber, prevBlockHash }) => buildBlockPreimage({
prevBlockHashHex: prevBlockHash,
blockNumber,
@@ -2039,12 +2043,14 @@ export class AuthService {
if (!owner || !Number.isFinite(root) || root < 0) throw new Error('Invalid channel selector');
if (owner !== blockchainName) throw new Error('Repost is allowed only to your own channels');
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || ''));
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64) {
if (rootHashHex === ZERO64 || !channelSlug) {
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === root);
if (!rootChannel) throw new Error('Channel root not found');
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
}
let prevLineNumber = root;
@@ -2089,6 +2095,7 @@ export class AuthService {
msgSubType: MSG_SUBTYPE_TEXT_REPOST,
msgVersion: 1,
bodyBytes,
channelSlug,
});
});
}
@@ -2115,11 +2122,15 @@ export class AuthService {
throw new Error('Invalid channel selector for edit');
}
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || ''));
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64) {
if (rootHashHex === ZERO64 || !channelSlug) {
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, ownerBlockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
if (rootChannel) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
if (rootChannel) {
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
}
}
let prevLineNumber = lineCode;
@@ -2165,6 +2176,7 @@ export class AuthService {
msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST,
msgVersion: 1,
bodyBytes,
channelSlug,
});
}
@@ -2375,6 +2387,7 @@ export class AuthService {
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: CREATE_CHANNEL_BODY_VERSION,
channelSlug,
bodyBytes: makeCreateChannelBodyBytes({
lineCode: 0,
prevLineNumber,
@@ -2413,13 +2426,16 @@ export class AuthService {
throw new Error('Posting is allowed only to your own channels');
}
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || ''));
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64) {
if (rootHashHex === ZERO64 || !channelSlug) {
const ownChannels = await this.listOwnChannelsForBlockchain(login, blockchainName);
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
if (!rootChannel) throw new Error('Channel root not found');
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
}
if (!channelSlug) throw new Error('Cannot resolve canonical channel slug');
let prevLineNumber = lineCode;
let prevLineHashHex = rootHashHex;
@@ -2462,6 +2478,7 @@ export class AuthService {
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelSlug,
};
}
@@ -2506,6 +2523,7 @@ export class AuthService {
msgSubType: cleanSubType,
msgVersion: 1,
bodyBytes,
channelSlug: tail.channelSlug,
});
return {
@@ -2547,6 +2565,7 @@ export class AuthService {
msgSubType: MSG_SUBTYPE_TEXT_CHANNEL_META,
msgVersion: 1,
bodyBytes,
channelSlug: tail.channelSlug,
});
return {