diff --git a/PATCH_MANIFEST.md b/PATCH_MANIFEST.md new file mode 100644 index 00000000..14d6eeb6 --- /dev/null +++ b/PATCH_MANIFEST.md @@ -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=`. +- 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-среде. diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java new file mode 100644 index 00000000..20977bb7 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java @@ -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 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 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 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 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; + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java deleted file mode 100644 index 55eff901..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java +++ /dev/null @@ -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._-]", "_"); - } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java deleted file mode 100644 index f4c58918..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java +++ /dev/null @@ -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 allowedPublishers, - int intervalMinutes, - String arweaveGateway, - Path workDir -) { - public static ArchiveImportConfig load() { - AppConfig c = AppConfig.getInstance(); - Set 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(); } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java deleted file mode 100644 index 1fad1e6c..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java +++ /dev/null @@ -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; - } - } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java deleted file mode 100644 index 06b6cf80..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java +++ /dev/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 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 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 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; - } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java deleted file mode 100644 index 0e6d4b37..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java +++ /dev/null @@ -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; - } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java deleted file mode 100644 index d4ed7e00..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java +++ /dev/null @@ -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; } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java deleted file mode 100644 index 10afac77..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java +++ /dev/null @@ -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(); - } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java deleted file mode 100644 index 0826fc8d..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java +++ /dev/null @@ -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 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 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 chains = archiveDao.listJobChains(job.id()); - if (chains.isEmpty()) throw new IllegalStateException("SNAPSHOT_CREATED job не содержит chains: " + job.id()); - Map> 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 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 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 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();} -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java deleted file mode 100644 index 2711d4c7..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java +++ /dev/null @@ -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> tagList = new ArrayList<>(); - List> 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 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 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 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 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 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 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> json, List> raw, String name, String value) { - byte[] n=utf8(name), v=utf8(value); - Map 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) {} -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java new file mode 100644 index 00000000..4f592ea1 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java @@ -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(); } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java new file mode 100644 index 00000000..0a314b63 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java @@ -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 candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems()); + if (candidates.isEmpty()) return 0; + + List items = new ArrayList<>(); + List 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 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(); + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java new file mode 100644 index 00000000..b5c1369b --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java @@ -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();} +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java new file mode 100644 index 00000000..2d357050 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java @@ -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 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 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 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 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);} } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java new file mode 100644 index 00000000..116be97f --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java @@ -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));} +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java new file mode 100644 index 00000000..db5ca59e --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java @@ -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 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> tagList = new ArrayList<>(); + List> jsonTags = new ArrayList<>(); + for (Tag tag : tags == null ? List.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 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 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() 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 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 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 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> json,List> raw,String name,String value){byte[]n=utf8(name),v=utf8(value);Mapm=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){} +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java index a33c81ef..98b688cf 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java @@ -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 chunks = chunkBytes(data); + List 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 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 chunkBytes(byte[] data) { + List 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 chunks = chunkFile(file, fileSize); diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java deleted file mode 100644 index 9d705429..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java +++ /dev/null @@ -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 rawRecords, - long previousBigBlockRef, long previousChunkOffset, long previousChunkSize) {} - public record Document(long bigBlockNumber, long createdAtMs, String creatorLogin, - List references, List 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 refs=new ArrayList<>((int)Math.min(refsCount, Integer.MAX_VALUE)); - for(long i=0;i= refs.size()) throw new IOException("parent_reference_index вне таблицы"); - - List chunks=new ArrayList<>((int)Math.min(chunksCount, Integer.MAX_VALUE)); - long parsedRecords=0; - for(long ci=0;ci 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 records=new ArrayList<>((int)Math.min(count,Integer.MAX_VALUE)); - for(long ri=0;riInteger.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); } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java deleted file mode 100644 index e1e99d4e..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java +++ /dev/null @@ -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 chunks, long fileSize) {} - - public BuildResult build( - Path finalPath, - String creatorLogin, - long bigBlockNumber, - long createdAtMs, - List previousBigBlocks, - List chains, - Map> 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 sortedChains = new ArrayList<>(chains); - sortedChains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName)); - - long totalRecords = 0; - for (ArchivePublishJobChain ch : sortedChains) { - List 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 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 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 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 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 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); } - } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java deleted file mode 100644 index 33ea9c65..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java +++ /dev/null @@ -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 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 ix = new HashMap<>(); - for (int i=0;i 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 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 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) {} -} diff --git a/SHiNE-server/shine-server-archive/src/test/java/server/archive/Ans104BundleTest.java b/SHiNE-server/shine-server-archive/src/test/java/server/archive/Ans104BundleTest.java new file mode 100644 index 00000000..2b83e9e1 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/test/java/server/archive/Ans104BundleTest.java @@ -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 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 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); + } +} diff --git a/SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java b/SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java deleted file mode 100644 index 5f394f39..00000000 --- a/SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java +++ /dev/null @@ -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)); - } -} diff --git a/SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java b/SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java deleted file mode 100644 index 7fdcd2de..00000000 --- a/SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java +++ /dev/null @@ -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; - } -} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java new file mode 100644 index 00000000..55ae570b --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java @@ -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 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 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 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 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 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 tags, byte[] data) { + requireLen(signature64, SIGNATURE_LEN, "signature"); + requireLen(owner32, OWNER_LEN, "owner"); + List 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 validateTags(List tags) { + List 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 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 parseAvroTags(byte[] raw, long declaredCount) { + AvroCursor c = new AvroCursor(raw); + List 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(); + } +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java index 255644d3..2e6d77be 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java @@ -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); } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java index ca657e80..8a8ddb49 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java @@ -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(); } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java b/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java deleted file mode 100644 index a7550fb9..00000000 --- a/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java +++ /dev/null @@ -1,297 +0,0 @@ -package utils.files; - -import java.io.IOException; -import java.nio.file.*; -import java.util.Objects; - -/** - * =============================================================== - * FileStoreUtil — утилита работы с файлами в папке data/. - * - * Теперь поддерживает: - * - основной файл блокчейна: .bch - * - временный файл блокчейна: .tmp_bch - * - sidecar-файл проверки записи: .write_check - * - marker-файл записи: .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); - } - } - - /* ===================================================================== */ - /* ===================== Блокчейн-файлы по имени ======================= */ - /* ===================================================================== */ - - /** .bch */ - public String buildBlockchainFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_FILE_EXTENSION; - } - - /** .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); - } - - /** .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)); - } - - /** .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]); - } - - /** .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)); - } - - /** - * Атомарно заменить основной файл блокчейна временным: - * .tmp_bch -> .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); - } - } -} diff --git a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java new file mode 100644 index 00000000..ec120078 --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java @@ -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 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 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 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; + } + +} diff --git a/SHiNE-server/shine-server-crypto/src/main/java/utils/crypto/README.md b/SHiNE-server/shine-server-crypto/src/main/java/utils/crypto/README.md index 3e132bed..20b4dcc5 100644 --- a/SHiNE-server/shine-server-crypto/src/main/java/utils/crypto/README.md +++ b/SHiNE-server/shine-server-crypto/src/main/java/utils/crypto/README.md @@ -1,7 +1,7 @@ # utils.crypto Пакет отвечает за криптографию — подписи и хэши блоков. -Используется при создании и проверке целостности `.bch`-блоков. +Используется при создании и проверке криптографических сущностей SHiNE; пользовательские blockchain-файлы `.bch` больше не используются. --- diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index fa153599..b5f838ff 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -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; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java deleted file mode 100644 index 027d6b03..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java +++ /dev/null @@ -1,8 +0,0 @@ -package shine.db.archive; - -/** Ссылка на уже успешно опубликованный большой архивный блок. */ -public record ArchiveBigBlockRef( - long bigBlockNumber, - byte[] archiveHash, - byte[] arweaveTxId -) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java deleted file mode 100644 index 111afa2e..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java +++ /dev/null @@ -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 -) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java deleted file mode 100644 index db9cd0ea..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java +++ /dev/null @@ -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 -) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java deleted file mode 100644 index 9fff0a20..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java +++ /dev/null @@ -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 -) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java deleted file mode 100644 index 36a665a8..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java +++ /dev/null @@ -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 -) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java deleted file mode 100644 index a3ddeb6a..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java +++ /dev/null @@ -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 -) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java deleted file mode 100644 index 6fd3def3..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java +++ /dev/null @@ -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 listPendingPublisherHeads(Set approvedLogins) throws SQLException { - if (approvedLogins == null || approvedLogins.isEmpty()) return List.of(); - List 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) {} -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java deleted file mode 100644 index 0d241896..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java +++ /dev/null @@ -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 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 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 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 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 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 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 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 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); - } -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java new file mode 100644 index 00000000..371fbf9d --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java @@ -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 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 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); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java index 2cfc5645..4f461d3f 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java @@ -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. */ diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java index 2719c31c..1b40ffe0 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java @@ -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 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 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 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 listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} } + public List 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 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 listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException { - try (Connection c = db.getConnection()) { - return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive); - } - } - - public List 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 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);} } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java index 762a4292..d9076c99 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java @@ -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; + } + } + } + } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java index 923bff29..560945a4 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java @@ -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")); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java index 39256dc4..12df97cf 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java @@ -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; } diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v24.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v24.sql new file mode 100644 index 00000000..cba0781a --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v24.sql @@ -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; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index d9c1365b..9109d1c4 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -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; - diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index a7a9b21a..10406a25 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -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), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 8ccf7b43..18829f3e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -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=. + 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; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java index 936b3c1d..90e5a19a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java @@ -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) собираем .tmp_bch как готовый кандидат на замену основного файла; - * 2) пишем маленький sidecar .write_check с blockNumber/blockHash; - * 3) создаём пустой marker .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 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 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(); - } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java deleted file mode 100644 index 210b2a6f..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java +++ /dev/null @@ -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();} -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java deleted file mode 100644 index a8189cb4..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java +++ /dev/null @@ -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;} -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java deleted file mode 100644 index cbe64495..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java +++ /dev/null @@ -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;} -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java index 44fa49cf..22555c14 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java @@ -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 withBypass(String blockchainName, ThrowingSupplier supplier) throws Exception { diff --git a/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java b/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java deleted file mode 100644 index 8c79f513..00000000 --- a/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java +++ /dev/null @@ -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 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 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 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 listMarkerFiles(Path dataDir) { - try (DirectoryStream 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 stream(DirectoryStream ds) { - return java.util.stream.StreamSupport.stream(ds.spliterator(), false) - .filter(Files::isRegularFile); - } - - private static Map parseMarker(Path path) { - try { - List lines = Files.readAllLines(path, StandardCharsets.UTF_8); - Map 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); - } -} diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java index a007d58d..3cab2c47 100644 --- a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java +++ b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java @@ -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()); diff --git a/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java b/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java index 491bbb1d..1e0ec914 100644 --- a/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java +++ b/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java @@ -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." diff --git a/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java b/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java deleted file mode 100644 index 8e063dff..00000000 --- a/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java +++ /dev/null @@ -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) собирается .tmp_bch как полный кандидат на замену main; - * 2) пишется sidecar .write_check (blockNumber/blockHash); - * 3) создаётся пустой marker .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 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 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 tmpFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION); - List 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 parseKeyValueFile(Path path) { - Map 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 listFilesWithSuffix(Path dataDir, String suffix) { - List out = new ArrayList<>(); - try (DirectoryStream 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); - } - } -} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 76ba4871..9a84e00a 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -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(); diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 577684e5..ea79ebe2 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -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=. +# ============================================================ +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 diff --git a/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java b/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java index f70ffb06..7e555a78 100644 --- a/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java +++ b/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java @@ -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 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 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(); } + } \ No newline at end of file diff --git a/VERSION.properties b/VERSION.properties index 08f9664b..49d7b524 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.19 -server.version=1.10.7 +client.version=1.12.20 +server.version=1.10.8 diff --git a/docs/API/04_Add_Block_to_Blockchain_API.md b/docs/API/04_Add_Block_to_Blockchain_API.md index 576662cf..d6fac193 100644 --- a/docs/API/04_Add_Block_to_Blockchain_API.md +++ b/docs/API/04_Add_Block_to_Blockchain_API.md @@ -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=`; +- `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 - - -Текст сообщения -``` - -Пример вложения с отдельным preview-файлом: - -```text - -``` - -Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`. - -### Создание публичного канала с профилем - -Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст: - -```text - - -Описание канала -``` - -Ограничение `channelDescription` — до `2048` UTF-8 байт. Новый клиент не пишет отдельный `TEXT_CHANNEL_META` сразу после создания канала: создание канала и начальный профиль должны попадать в один `TECH_CREATE_CHANNEL`. - -### Изменение профиля канала - -Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=90)`. - -Текстовое содержимое body использует тот же формат полного снимка профиля: - -```text - - -Новое описание канала -``` - -Каждый `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 использует ту же проверку, но не ставит блок на повторную публикацию. diff --git a/docs/Archive/01_PROTOCOL_v1.0.md b/docs/Archive/01_PROTOCOL_v1.0.md deleted file mode 100644 index 8bef6d09..00000000 --- a/docs/Archive/01_PROTOCOL_v1.0.md +++ /dev/null @@ -1,1647 +0,0 @@ -# SHiNE Archive Protocol v1.0 - -## Полная спецификация серверной архивации SHiNE-блоков в Arweave и фиксации головы архива в Solana - -**Статус:** согласованная спецификация v1.0 -**Язык:** русский -**Magic:** `SHINE-ARCHIVE` -**Версия протокола:** `1.0` -**Endian:** big-endian для всех фиксированных целых чисел - ---- - -# 1. Назначение - -Этот документ описывает механизм, по которому SHiNE-сервер периодически: - -1. синхронизирует обычные SHiNE-блоки существующим механизмом; -2. определяет новые локальные блоки, ещё не попавшие в архив; -3. фиксирует неизменяемую дельту; -4. группирует все новые записи одной `blockchain_name` в один блок пользователя внутри большого архивного блока; -5. добавляет к каждому такому пользовательскому блоку одну ссылку на предыдущий пользовательский блок этой же `blockchain_name`; -6. формирует один большой бинарный блок `SHINE-ARCHIVE`; -7. загружает его в Arweave; -8. ждёт закрепления/подтверждения Arweave-транзакции; -9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; -10. только после Solana `finalized` переводит локальные курсоры на новую точку. - -На первом этапе предполагается один архивный server, но формат изначально не должен запрещать появление других архиваторов. - ---- - -# 2. Основные понятия - -## 2.1. Исходный SHiNE-блок / запись - -Это существующие в серверной БД оригинальные `block_bytes`, относящиеся к конкретной `blockchain_name`. - -Они уже содержат пользовательские данные и существующую криптографию SHiNE. - -Архиватор: - -- не изменяет их; -- не переподписывает их; -- не переводит их в Base64; -- не пересобирает их содержимое. - -## 2.2. `blockchain_name` - -В архивном формате основной идентификатор пользовательской цепочки — именно `blockchain_name`. - -Отдельно хранить `login` для пользовательского блока не требуется. - -`blockchain_name` уже однозначно идентифицирует конкретную пользовательскую blockchain и логически включает пользовательскую идентичность + номер/вариант цепочки. - -## 2.3. Большой архивный блок - -Один файл/объект `SHINE-ARCHIVE`, который сервер формирует за один цикл архивной публикации. - -Он содержит: - -- header; -- список предыдущих больших архивных блоков; -- по одному `UserBlockchainChunk` для каждой `blockchain_name`, у которой есть новые записи; -- footer; -- SHA-256 большого блока; -- подпись сервера, который этот большой блок сформировал и закрыл. - -Один большой архивный блок после публикации соответствует одной Arweave-транзакции. - -## 2.4. `UserBlockchainChunk` - -В одном большом архивном блоке для одной `blockchain_name` существует максимум один такой chunk. - -Если за текущую дельту у пользователя/цепочки накопилось 1, 3, 5 или 20 записей, все они помещаются внутрь одного `UserBlockchainChunk`. - -Навигационная ссылка одна на весь chunk. - ---- - -# 3. Главная модель пользовательской истории - -Допустим `alice-001` имела записи: - -```text -BigBlock 70: - AliceChunk = 2 записи - -BigBlock 81: - AliceChunk = 5 записей - -BigBlock 95: - AliceChunk = 3 записи - -BigBlock 100: - AliceChunk = 4 записи -``` - -Тогда история `alice-001` выглядит: - -```text -BigBlock 100 - AliceChunk - 4 записи - | - +--> PreviousBlockchainChunkRef - | - v -BigBlock 95 - AliceChunk - 3 записи - | - +--> PreviousBlockchainChunkRef - | - v -BigBlock 81 - AliceChunk - 5 записей - | - v -BigBlock 70 -``` - -Навигация идёт пачками записей одной blockchain, а не по одной записи за раз. - ---- - -# 4. Сервер сам добавляет навигацию - -Пользователь не обязан знать: - -- в какой большой архивный блок попадут его записи; -- какой будет Arweave TX ID; -- какие будут offsets; -- где находится предыдущий chunk. - -Все эти данные сервер добавляет при формировании большого блока. - -Исходные подписанные пользовательские `block_bytes` остаются неизменными. - -`PreviousBlockchainChunkRef` является внешней серверной архивной метаинформацией и защищается hash + подписью всего большого архивного блока. - ---- - -# 5. Необходимые ключи архивного сервера - -Архивный server хранит: - -## 5.1. SHiNE root private key - -Используется: - -- для подписания новой версии User PDA при обычном `update_user_pda`; -- в v1.0 — для подписи закрытого большого архивного блока. - -## 5.2. SHiNE client private key - -Используется как Solana transaction signer / fee payer в соответствии с текущей логикой `shine_users`. - -## 5.3. Arweave JWK / private wallet key - -Используется для загрузки и оплаты Arweave-транзакции. - -## 5.4. Blockchain private key - -Для архивной публикации не требуется. - -Архиватор не создаёт новые пользовательские blockchain-блоки. - ---- - -# 6. Расписание публикации - -Архивная публикация запускается один раз в сутки в заданное локальное время. Она НЕ использует интервал «каждые N минут». - -Пример: - -```properties -archive.publish.enabled=true -archive.publish.time=00:00 -archive.publish.zoneId= -``` - -Для v1.0 значение по умолчанию: - -```text -00:00 -``` - -То есть новый snapshot и новый большой архивный блок создаются один раз в сутки в полночь. - -Если `archive.publish.zoneId` пуст, используется системная timezone сервера. При необходимости её можно задать явно, например `Europe/Warsaw`. Это сохраняет публикацию ровно в указанное локальное время даже при переходах летнего/зимнего времени. - -Незавершённый archive job после рестарта не ждёт следующей полуночи: сервер продолжает именно его сразу. Новый snapshot при старте вне назначенного времени не создаётся. - -## 6.1. Первая архивная публикация - -Если у данного archive publisher ещё нет подтверждённых архивных курсоров, первая публикация берёт ВСЁ локально известное состояние: - -```text -для каждой blockchain_name: -source block 0 .. current local head -``` - -То есть первый большой архивный блок содержит все SHiNE-блоки, которые сервер успел узнать к моменту первого суточного snapshot. После успешной публикации следующие большие блоки содержат только дельту относительно подтверждённых курсоров. - -## 6.2. Локальная папка и имена файлов - -Перед любой сетевой загрузкой большой блок сначала полностью создаётся на локальном диске. По умолчанию каталог: - -```text -data/archive/ -``` - -До получения Arweave TX ID файл имеет временное имя: - -```text -.<00001>.<дд.мм.гг>.tmp.SHiNE-archive -``` - -Например: - -```text -archive01.00001.11.09.26.tmp.SHiNE-archive -``` - -Дата — реальная дата создания/freeze snapshot большого блока в timezone archive publisher-а. Номер имеет минимальную ширину 5 цифр. Пять цифр — форматирование, а не лимит: блок `100000` получает шестизначный номер. - -После успешной загрузки Arweave возвращает реальный TX ID. Сервер сначала надёжно сохраняет TX ID в БД, затем атомарно переименовывает тот же локальный файл в: - -```text -.<00001>.<дд.мм.гг>..SHiNE-archive -``` - -Например: - -```text -archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive -``` - -Поле `` — не слово `trx`, а настоящий Base64URL TX ID загруженного объекта в Arweave. Финальный файл остаётся локально как постоянная копия. - -Crash recovery обязан продолжать работу с этим же файлом. Если TX ID уже сохранён, но процесс упал до rename, при следующем запуске сервер вычисляет финальное имя из сохранённого TX ID и завершает переименование без повторной сборки дельты. - ---- - -# 7. Состояние, относительно которого считается дельта - -Нужно различать: - -1. уже окончательно опубликованное состояние; -2. текущий замороженный job, который ещё проходит Arweave/Solana. - -Нельзя считать дельту от живой головы во время уже запущенной публикации. - ---- - -# 8. Таблица `archive_chain_cursor` - -Хранит окончательно подтверждённое архивное состояние для каждой `blockchain_name`. - -```sql -CREATE TABLE 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, - last_archive_big_block_hash BYTEA, - - last_chunk_offset BIGINT, - last_chunk_size BIGINT, - - updated_at_ms BIGINT NOT NULL -); -``` - -Назначение: - -- `last_archived_source_block_number` — до какого исходного SHiNE-блока этой blockchain всё успешно опубликовано; -- `last_archived_source_block_hash` — hash этого исходного блока; -- `last_archive_big_block_number` и `last_archive_big_block_hash` — в каком большом `SHINE-ARCHIVE` находится последний chunk этой blockchain; -- `last_chunk_offset` и `last_chunk_size` — точное положение последнего chunk внутри большого блока. - -Этого достаточно, чтобы следующая публикация создала один backlink на предыдущий chunk. - ---- - -# 9. Отдельный индекс по каждой записи не нужен - -Поскольку все новые записи одной `blockchain_name` агрегируются в один `UserBlockchainChunk`, не нужно хранить offsets каждой отдельной записи в истории. - -Для следующей публикации достаточно помнить: - -```text -предыдущий big block -предыдущий big block hash -chunk offset -chunk size -``` - ---- - -# 10. Таблица `archive_publish_job` - -Хранит crash-safe состояние одной архивной публикации. - -```sql -CREATE TABLE 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 -); -``` - -Рекомендуемые статусы: - -```text -SNAPSHOT_CREATED -FILE_BUILT -ARWEAVE_UPLOADED -ARWEAVE_CONFIRMED -SOLANA_SUBMITTED -SOLANA_FINALIZED -CURSORS_COMMITTED -FAILED -``` - -Завершённые rows этой таблицы одновременно дают локальную историю больших блоков: - -```text -big_block_number -archive_hash -arweave_tx_id -``` - -По ним строится FULL-список предыдущих больших блоков следующей публикации. - ---- - -# 11. Таблица `archive_publish_job_chain` - -Хранит frozen range каждой blockchain в текущем job и координаты создаваемого chunk. - -```sql -CREATE TABLE archive_publish_job_chain ( - job_id BIGINT NOT NULL, - 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) -); -``` - ---- - -# 12. Заморозка дельты - -Пример: - -```text -локально: -alice-001 = source block 180 -bob-001 = source block 94 - -опубликовано: -alice-001 = 160 -bob-001 = 90 -``` - -Новый job фиксирует: - -```text -alice-001: 161..180 -bob-001: 91..94 -``` - -Если во время загрузки появятся: - -```text -alice-001 181..185 -bob-001 95..97 -``` - -они попадут только в следующий job. - ---- - -# 13. Проверка курсора перед публикацией - -Перед созданием frozen range сервер проверяет: - -```text -local hash(last_archived_source_block_number) -== -archive_chain_cursor.last_archived_source_block_hash -``` - -При несовпадении публикация этой chain останавливается до resync/fork handling. - ---- - -# 14. Ограничение размера большого блока - -Offsets и sizes внутри `SHINE-ARCHIVE v1.0` используют `u32`. - -Поэтому один большой archive-файл MUST быть меньше `4 GiB`. - -Рекомендуемый запас: - -```properties -archive.maxFileBytes=4000000000 -``` - -Если собранный frozen job превышает этот лимит, v1.0 останавливает публикацию с явной ошибкой `ArchiveTooLargeException` и не двигает курсоры. Практически лимит очень велик; для такого сервера следует уменьшить объём данных между суточными закрытиями или реализовать деление snapshot на несколько big blocks. Автоматическое деление одного snapshot на несколько big blocks оставлено как совместимое будущее расширение. - ---- - -# 15. Magic и версия - -Файл начинается ASCII: - -```text -SHINE-ARCHIVE -``` - -Размер: 13 байт. - -Далее: - -```text -u8 version_major -u8 version_minor -``` - -Для v1.0: - -```text -1 -0 -``` - ---- - -# 16. Общий layout большого блока - -```text -+------------------------------------------+ -| FIXED HEADER | -+------------------------------------------+ -| creator_login | -+------------------------------------------+ -| PREVIOUS BIG BLOCK REFERENCES | -| ref[0] | -| ref[1] | -| ... | -+------------------------------------------+ -| UserBlockchainChunk #1 | -| UserBlockchainChunk #2 | -| ... | -+------------------------------------------+ -| FOOTER: closer_login | -| block_hash | -| signature | -+------------------------------------------+ -``` - ---- - -# 17. Fixed Header v1.0 - -Все integers — big-endian. - -| Поле | Тип | Размер | -|---|---:|---:| -| magic | bytes[13] | 13 | -| version_major | u8 | 1 | -| version_minor | u8 | 1 | -| header_size | u32 | 4 | -| big_block_number | u32 | 4 | -| created_at_ms | u64 | 8 | -| creator_login_length | u8 | 1 | -| references_mode | u8 | 1 | -| references_count | u32 | 4 | -| reference_entry_size | u16 | 2 | -| parent_reference_index | u32 | 4 | -| blockchain_chunks_count | u32 | 4 | -| total_user_records_count | u32 | 4 | - -После fixed header немедленно идут `creator_login` bytes. - -`header_size` — byte offset первого `PreviousBigBlockReference`. - ---- - -# 18. `creator_login` - -SHiNE login пользователя/сервера, который сформировал большой блок. - -```text -u8 creator_login_length -bytes[N] creator_login UTF-8 -``` - -Этот login находится в начале файла, поэтому его можно узнать без скачивания payload. - ---- - -# 19. `big_block_number` - -```text -u32 -``` - -Рекомендуемая нумерация опубликованных больших блоков: - -```text -first = 1 -next = 2 -next = 3 -... -``` - -Первый реально публикуемый большой блок имеет номер `1`, поэтому его локальное имя содержит `00001`. Неудачная незавершённая попытка не становится частью опубликованной archive-цепочки. - ---- - -# 20. `created_at_ms` - -```text -u64 -``` - -Unix Epoch UTC milliseconds. - -Это время заморозки содержимого big block. - ---- - -# 21. Режим ссылок на предыдущие большие блоки - -```text -references_mode u8 -``` - -Значения: - -```text -0 = FULL -1 = PARTIAL, зарезервировано на будущее -``` - -Writer v1.0 MUST использовать `FULL`. - ---- - -# 22. FULL previous-block table - -Новый big block содержит ссылки на ВСЕ предыдущие большие блоки своей ветки. - -Пример: - -```text -BigBlock #365 - -References: -#1 -#2 -#3 -... -#364 -``` - -При одном big block в день через год таблица занимает примерно 24.8 KiB. - ---- - -# 23. `PreviousBigBlockReference` - -Фиксированный размер: 68 байт. - -| Поле | Тип | Размер | -|---|---:|---:| -| big_block_number | u32 | 4 | -| big_block_hash | bytes[32] | 32 | -| arweave_tx_id | bytes[32] | 32 | - -Семантика: - -```text -big_block_number = логический номер -big_block_hash = криптографическая идентичность -arweave_tx_id = где скачать блок -``` - ---- - -# 24. `references_count` - -Количество entries в таблице ссылок. - -```text -u32 -``` - ---- - -# 25. `reference_entry_size` - -```text -u16 -``` - -Для v1.0: - -```text -68 -``` - -Поле оставлено для будущего расширения entry. - ---- - -# 26. `parent_reference_index` - -```text -u32 -``` - -Индекс непосредственного родителя внутри reference table. - -Для первого большого блока (`#1`), у которого нет родителя: - -```text -0xFFFFFFFF -``` - -В простой FULL-линейной ветке обычно это последний entry. - ---- - -# 27. Быстрое скачивание начала блока - -Клиент сначала скачивает fixed header + creator login. - -Из него узнаёт: - -```text -header_size -references_count -reference_entry_size -``` - -Затем скачивает только reference table и уже имеет адреса/hash всех предыдущих больших блоков. - -Payload пользователей можно пока не скачивать. - ---- - -# 28. `blockchain_chunks_count` - -```text -u32 -``` - -Количество `UserBlockchainChunk` в текущем big block. - -Одна `blockchain_name` встречается максимум один раз. - ---- - -# 29. `total_user_records_count` - -```text -u32 -``` - -Общее количество исходных SHiNE-записей во всех chunks. - ---- - -# 30. Порядок chunks и records - -Для детерминированной сериализации chunks SHOULD сортироваться по `blockchain_name ASC`. - -Внутри chunk исходные записи MUST идти по возрастанию номера исходного SHiNE-блока. - ---- - -# 31. Формат `UserBlockchainChunk` - -```text -u32 chunk_size - -u8 blockchain_name_length -bytes[N] blockchain_name UTF-8 - -u32 records_count - -repeat records_count: - u32 record_size - bytes[M] raw_record_bytes - -u32 previous_big_block_ref -u32 previous_chunk_offset -u32 previous_chunk_size -``` - ---- - -# 32. `chunk_size` - -```text -u32 -``` - -Полный размер chunk от первого байта `chunk_size` до последнего байта `previous_chunk_size` включительно. - -Это позволяет: - -- быстро пропустить chunk; -- скачать его Range-запросом; -- сохранить его точные координаты для следующего backlink. - ---- - -# 33. `blockchain_name` внутри chunk - -Хранится только `blockchain_name`. - -Отдельный `login` не нужен. - ---- - -# 34. `records_count` - -```text -u32 -``` - -Если за период у `alice-001` появилось 7 записей: - -```text -records_count = 7 -``` - -Все 7 находятся в одном chunk. - ---- - -# 35. Raw records - -Каждая исходная запись сериализуется: - -```text -u32 record_size -bytes[record_size] raw_record_bytes -``` - -`raw_record_bytes` — оригинальные данные SHiNE из БД. - -Сервер не меняет их содержимое. - ---- - -# 36. Один backlink на весь chunk - -После всех raw records находится ровно один `PreviousBlockchainChunkRef`. - -Он относится ко всей пачке записей blockchain. - -Он не повторяется у отдельных записей. - ---- - -# 37. `PreviousBlockchainChunkRef` - -| Поле | Тип | Размер | -|---|---:|---:| -| previous_big_block_ref | u32 | 4 | -| previous_chunk_offset | u32 | 4 | -| previous_chunk_size | u32 | 4 | - -Итого 12 байт на весь chunk. - ---- - -# 38. `previous_big_block_ref` - -Индекс в `PreviousBigBlockReferences[]` текущего big block. - -Он указывает на тот предыдущий big block, где находится предыдущий chunk этой же `blockchain_name`. - -Это именно reference index, а не номер блока. - ---- - -# 39. `previous_chunk_offset` - -```text -u32 -``` - -Byte offset от начала предыдущего `SHINE-ARCHIVE` до первого байта предыдущего chunk этой blockchain. - -Первый байт chunk — поле `chunk_size`. - ---- - -# 40. `previous_chunk_size` - -```text -u32 -``` - -Полный размер предыдущего chunk. - -Поэтому клиент может Range-запросом забрать сразу всю предыдущую пачку записей пользователя. - ---- - -# 41. Первая публикация blockchain - -Если у данной `blockchain_name` нет предыдущего архивного chunk: - -```text -previous_big_block_ref = 0xFFFFFFFF -previous_chunk_offset = 0 -previous_chunk_size = 0 -``` - ---- - -# 42. Пример backlink-цепочки - -В `BigBlock #100` есть `alice-001` chunk из 4 записей. - -Его backlink: - -```text -previous_big_block_ref = 94 -previous_chunk_offset = 8123 -previous_chunk_size = 1770 -``` - -`ref[94]` содержит: - -```text -big_block_number = 95 -big_block_hash = ... -arweave_tx_id = ... -``` - -Клиент: - -1. получает TX big block #95; -2. проверяет hash; -3. делает Range на `8123 .. 8123+1770`; -4. получает весь предыдущий `alice-001` chunk; -5. читает его записи; -6. берёт следующий backlink. - ---- - -# 43. Почему TX ID не хранится в каждом chunk - -TX ID и hash предыдущих больших блоков уже один раз записаны в общей reference table. - -Поэтому в chunk достаточно компактного `u32 previous_big_block_ref`. - ---- - -# 44. Footer большого блока - -После последнего chunk: - -```text -u8 closer_login_length -bytes[N] closer_login UTF-8 - -bytes[32] block_hash -bytes[64] closer_signature -``` - ---- - -# 45. `closer_login` - -Login пользователя/сервера, который сформировал и закрыл big block. - -В v1.0: - -```text -closer_login MUST == creator_login -``` - -Login повторяется намеренно: - -- header даёт автора сразу; -- footer явно фиксирует того, кто закрыл блок; -- hash покрывает footer login; -- verifier проверяет совпадение. - ---- - -# 46. `block_hash` - -```text -bytes[32] -SHA-256 -``` - -Хэшируется всё от первого байта magic до последнего байта `closer_login` включительно. - -Не входят: - -```text -block_hash -closer_signature -``` - -Псевдокод: - -```text -hash_input = - fixed_header - + creator_login - + references - + all UserBlockchainChunks - + closer_login_length - + closer_login - -block_hash = SHA256(hash_input) -``` - ---- - -# 47. `closer_signature` - -```text -bytes[64] -Ed25519 -``` - -Подписывается: - -```text -ASCII("SHINE-ARCHIVE-V1") || block_hash -``` - -Для v1.0 используется root private key аккаунта `creator_login`. - ---- - -# 48. Проверка подписи - -Verifier: - -1. читает `creator_login`; -2. проверяет `creator_login == closer_login`; -3. получает root public key аккаунта из User PDA / исторического состояния Solana; -4. пересчитывает `block_hash`; -5. проверяет Ed25519 signature. - -Старые архивы должны оставаться проверяемыми после ротации root key, поэтому исторические PDA-состояния нужно сохранять/уметь получать. - ---- - -# 49. Собственный Arweave TX ID текущего блока - -Текущий файл не может заранее знать свой будущий TX ID. - -Поэтому собственный TX ID: - -- не входит в сам текущий файл; -- сохраняется после upload; -- записывается в Solana PDA; -- появляется как previous-block reference уже в следующем big block. - ---- - -# 50. User PDA block type `100` - -Archive head хранится прямо в существующем User PDA publisher-а. - -Формат: - -```text -u8 block_type = 100 -u8 block_version = 0 -bytes[32] archive_tx_id -bytes[32] archive_hash -``` - -Итого 66 байт. - -`archive_hash` = `block_hash` последнего успешно опубликованного big block. - ---- - -# 51. Обновление Solana - -Отдельная новая Solana instruction не требуется. - -Используется существующий `update_user_pda`. - -Нужно расширить существующие serializer/parser/codec так, чтобы block type 100: - -- читался; -- записывался; -- сохранялся обычными обновлениями; -- мог быть изменён архивным сервером обычным update User PDA. - ---- - -# 52. Полный порядок публикации - -```text -1. Существующая server-to-server sync независимо обновляет локальные blockchains. - -2. Scheduler запускает archive job. - -3. Проверяется отсутствие другого активного archive job. - -4. Читаются archive_chain_cursor. - -5. Читаются текущие локальные blockchain heads. - -6. Для каждой blockchain вычисляется frozen delta. - -7. Создаётся archive_publish_job. - -8. Создаются archive_publish_job_chain rows. - -9. Загружается история всех успешно finalized previous big blocks. - -10. Формируется fixed header. - -11. Записывается creator_login. - -12. Записывается FULL PreviousBigBlockReferences. - -13. Для каждой blockchain_name: - - читается frozen range; - - создаётся ровно один UserBlockchainChunk; - - внутрь кладутся все новые records; - - добавляется один PreviousBlockchainChunkRef; - - сохраняются new_chunk_offset/new_chunk_size. - -14. Записывается closer_login. - -15. Вычисляется SHA-256 block_hash. - -16. Создаётся Ed25519 closer_signature. - -17. Файл закрывается. - -18. Файл загружается в Arweave. - -19. TX ID сохраняется в archive_publish_job. - -20. Сервер ждёт требуемое число Arweave confirmations. - -21. Обычным update_user_pda обновляется block type 100: - archive_tx_id - archive_hash - -22. Сервер ждёт Solana finalized. - -23. В одной DB transaction обновляются archive_chain_cursor. - -24. Job получает CURSORS_COMMITTED. -``` - ---- - -# 53. Cursor commit - -Для каждой blockchain из job: - -```text -last_archived_source_block_number = to_source_block_number -last_archived_source_block_hash = last_source_block_hash - -last_archive_big_block_number = current big_block_number -last_archive_big_block_hash = current block_hash - -last_chunk_offset = new_chunk_offset -last_chunk_size = new_chunk_size -``` - -Следующая публикация сразу знает, куда должен вести backlink. - ---- - -# 54. Arweave service - -Старый `TestFreeAvatarArweaveService` удаляется из активного протокола; archive publisher использует отдельный `ArweaveArchiveService`. - -Его следует переделать/переименовать, например в: - -```text -ArweaveArchiveService -``` - -Он должен: - -- использовать Arweave JWK; -- подписывать и оплачивать upload; -- поддерживать большие/chunked uploads; -- возвращать TX ID; -- ждать подтверждения; -- опрашивать confirmations. - -Рекомендуемые настройки: - -```properties -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 -``` - ---- - -# 55. Solana finalization - -После обычного `update_user_pda` сервер должен дождаться: - -```text -finalized -``` - -Только после этого разрешается сдвинуть локальные курсоры. - ---- - -# 56. Crash recovery - -## После `SNAPSHOT_CREATED` - -Возобновить тот же frozen job. - -## После `FILE_BUILT` - -Использовать тот же локальный файл. - -## После `ARWEAVE_UPLOADED` - -Не загружать второй раз. Продолжить по сохранённому TX ID. - -## После `ARWEAVE_CONFIRMED` - -Продолжить Solana update. - -## После `SOLANA_SUBMITTED` - -Сначала проверить transaction / текущий PDA. - -## После `SOLANA_FINALIZED`, но до cursor commit - -Проверить: - -```text -PDA.archive_tx_id == expected_tx -PDA.archive_hash == expected_hash -``` - -После этого безопасно выполнить cursor commit. - ---- - -# 57. Нет новых данных - -Если новых записей нет ни в одной blockchain: - -```text -не создавать пустой SHINE-ARCHIVE -не загружать Arweave -не обновлять Solana -``` - ---- - -# 58. FULL references как стартовая стратегия - -v1.0 сознательно хранит ВСЕ previous big blocks в каждом новом big block. - -Плюсы: - -- актуальный блок сразу даёт карту всей предыдущей истории; -- не нужно идти parent-by-parent; -- backlink может использовать компактный ref index; -- reader очень простой. - -При одном big block в сутки: - -```text -1 год: ~24.8 KiB -5 лет: ~121 KiB -20 лет: ~485 KiB -``` - ---- - -# 59. PARTIAL references - -`references_mode = 1` зарезервирован на будущее. - -v1.0 writer его не реализует. - -В будущем PARTIAL может означать: - -- только реально используемые previous blocks; -- последние N blocks; -- checkpoint references; -- смешанную стратегию. - ---- - -# 60. Чтение истории одной blockchain - -Имея актуальный chunk `alice-001`, клиент: - -1. читает все records chunk; -2. читает `PreviousBlockchainChunkRef`; -3. если ref = `0xFFFFFFFF`, история закончилась; -4. иначе берёт соответствующий `PreviousBigBlockReference`; -5. получает TX ID + hash previous big block; -6. Range-запросом читает `previous_chunk_offset/previous_chunk_size`; -7. получает сразу всю предыдущую пачку записей `alice-001`; -8. повторяет. - -Так можно читать историю конкретной blockchain, не скачивая чужие chunks. - ---- - -# 61. Дубликаты - -Если исходная SHiNE-запись уже есть у получателя, она пропускается/дедуплицируется стандартной логикой. - ---- - -# 62. Конфликты исходной blockchain - -Архивная ветка сама по себе не является пользовательским конфликтом. - -Конфликт возникает, когда одна `blockchain_name` имеет два несовместимых валидно подписанных продолжения одной предыдущей точки. - -Для v1 безопасно: - -```text -сохранить обе ветки -пометить blockchain CONFLICTED -остановить автоматическое продолжение -``` - -Механизм разрешения fork можно добавить позже. - ---- - -# 63. Авторство большого блока - -Автор указан дважды: - -## Header - -```text -creator_login -``` - -## Footer - -```text -closer_login -block_hash -closer_signature -``` - -Для v1.0: - -```text -creator_login == closer_login -``` - -Это означает: именно этот SHiNE-аккаунт сформировал и криптографически закрыл big block. - ---- - -# 64. Подпись сервера не заменяет подписи пользователей - -`closer_signature` доказывает авторство большого archive block. - -Каждая пользовательская raw record всё равно проверяется существующей криптографией SHiNE. - ---- - -# 65. Независимость от Arweave - -Arweave TX ID — location. - -SHA-256 — identity. - -Поэтому архивы можно перенести на другой storage и всё равно проверять. - ---- - -# 66. Block type 100 в Solana — текущая голова - -User PDA publisher-а хранит только: - -```text -current archive TX ID -current archive SHA-256 -``` - -Детальная история находится внутри big blocks. - ---- - -# 67. Startup preflight - -Перед запуском archive publisher сервер SHOULD проверить: - -```text -derivePublic(root_private) == UserPDA.root_public_key -``` - -и: - -```text -derivePublic(client_private) == UserPDA.client_key -``` - -При несовпадении archive publisher не запускается. - ---- - -# 68. Рекомендуемые настройки - -```properties -archive.publish.enabled=false -archive.publish.time=00:00 -archive.publish.zoneId= -archive.workDir=data/archive - -archive.maxFileBytes=4000000000 - -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 - -archive.solana.rootKeyPath=/opt/shine/secrets/root.key -archive.solana.clientKeyPath=/opt/shine/secrets/client.key -archive.solana.commitment=finalized - -# Отдельного archive.solana.rpcUrl нет. -# Используется solana.users.sync.rpcUrl, а если он пуст — обычный solana.rpcUrl. -``` - ---- - -# 69. Требование к `AppConfig` - -Archive settings должны читаться из: - -- Java system properties; -- environment variables; -- `application.properties`. - -`getInt()` и `getBoolean()` должны использовать общую логику `getParam()`. - ---- - -# 70. Что переиспользуется из текущего сервера - -Существующие механизмы: - -```text -BlockchainStateDAO.listAll() -BlocksDAO.listRangeByNumber(...) -``` - -дают необходимые heads и raw block ranges. - -Используются существующие: - -```text -block_bytes -block_hash -block_signature -blockchain_name -block_number -``` - -Server-to-server sync в v1.0 не меняется. - ---- - -# 71. Блокировки - -Можно использовать существующий blockchain lock только на коротком этапе: - -```text -проверить cursor hash -прочитать frozen range -проверить конечный hash -сохранить/сериализовать frozen data -``` - -Lock не удерживается во время Arweave/Solana ожиданий. - ---- - -# 72. Реализационный checklist - -## Database - -- [x] `archive_chain_cursor` -- [x] `archive_publish_job` -- [x] `archive_publish_job_chain` -- [x] schema migration -- [x] crash recovery - -## Archive writer - -- [x] magic `SHINE-ARCHIVE` -- [x] major/minor version -- [x] fixed header -- [x] creator login -- [x] FULL previous big block table -- [x] one chunk per `blockchain_name` -- [x] many raw records inside one chunk -- [x] one backlink per chunk -- [x] closer login -- [x] SHA-256 -- [x] Ed25519 signature -- [x] max file size < 4 GiB -- [x] local `.tmp.SHiNE-archive -> ..SHiNE-archive` lifecycle in `data/archive` - -## Arweave - -- [x] rename/refactor `TestFreeAvatarArweaveService` -- [x] remove avatar-specific logic -- [x] large/chunked upload -- [x] confirmation polling - -## Solana - -- [x] add PDA block type `100` -- [x] update Rust codec -- [x] update Java codec -- [x] update JS codec/UI writer -- [x] use ordinary `update_user_pda` -- [x] server-side transaction writer -- [x] root signature -- [x] client fee payer -- [x] wait for `finalized` - -## Scheduler - -- [x] `archive.publish.enabled` -- [x] `archive.publish.time` -- [x] `archive.publish.zoneId` -- [x] no concurrent jobs -- [ ] future: автоматическое split > max size (v1.0 сейчас безопасно останавливается без cursor commit) -- [x] skip when no new blocks - ---- - -# 73. Итоговая бинарная схема v1.0 - -```text -SHINE-ARCHIVE -version_major u8 -version_minor u8 - -header_size u32 -big_block_number u32 -created_at_ms u64 - -creator_login_length u8 - -references_mode u8 -references_count u32 -reference_entry_size u16 -parent_reference_index u32 - -blockchain_chunks_count u32 -total_user_records_count u32 - -creator_login bytes - - -PreviousBigBlockReference[references_count]: - - big_block_number u32 - big_block_hash [32] - arweave_tx_id [32] - - -UserBlockchainChunk[blockchain_chunks_count]: - - chunk_size u32 - - blockchain_name_length u8 - blockchain_name bytes - - records_count u32 - - repeat records_count: - record_size u32 - raw_record_bytes - - previous_big_block_ref u32 - previous_chunk_offset u32 - previous_chunk_size u32 - - -FOOTER: - - closer_login_length u8 - closer_login bytes - - block_hash [32] - closer_signature [64] -``` - ---- - -# 74. Основная архитектура одним рисунком - -```text -SOLANA USER PDA - | - | block type 100 - | TX + HASH - v -BigBlock #100 - | - +-- Header - | creator = server-A - | - +-- References - | ref[0] -> BigBlock #1 / hash / TX - | ref[1] -> BigBlock #2 / hash / TX - | ... - | ref[98] -> BigBlock #99 / hash / TX - | - +-- alice-001 chunk - | records x4 - | | - | +--> BigBlock #95 / AliceChunk - | - +-- bob-001 chunk - | records x2 - | | - | +--> BigBlock #98 / BobChunk - | - +-- kate-002 chunk - | records x10 - | | - | +--> BigBlock #74 / KateChunk - | - +-- Footer - closer = server-A - SHA-256 - Ed25519 signature -``` - ---- - -# 75. Итоговые решения v1.0 - -- один большой блок = одна архивная публикация; -- в начале лежит полный список всех предыдущих больших блоков; -- каждый previous block reference содержит `number + SHA-256 + Arweave TX ID`; -- одна `blockchain_name` встречается в большом блоке максимум одним chunk; -- все новые записи этой blockchain объединяются в этот chunk; -- chunk имеет одну ссылку на предыдущий chunk этой же blockchain; -- backlink состоит из `previous big block ref + chunk offset + chunk size`; -- сервер сам создаёт backlink; -- пользовательские raw records не меняются; -- login сервера есть в header и footer; -- большой блок закрывается SHA-256 + Ed25519 signature сервера; -- Solana User PDA block `100` хранит текущий TX ID + archive hash; -- PDA обновляется обычным `update_user_pda`; -- Arweave используется как долговременное хранилище; -- публикация считается завершённой только после Arweave confirmation + Solana finalized + cursor commit. - - ---- - -## Trusted archive reader / importer - -Бинарный формат v1.0 не меняется. Обычный сервер MAY использовать archive head из User PDA для восстановления недостающих SHiNE-блоков, но SHOULD принимать архивы только от явно разрешённых server logins. - -Рекомендуемая политика v1: - -```text -publisher login ∈ local whitelist -AND UserPDA.is_server = true -AND SHA256(file) == UserPDA.archive_head_hash / reference hash -AND creator_login == closer_login == publisher login -AND Ed25519 archive signature valid for publisher root key -AND every raw SHiNE block passes normal AddBlock validation -``` - -FULL reference table head-блока позволяет поздно подключившемуся серверу импортировать всю неизвестную историю publisher-а от старых big blocks к новым. - -Для быстрого пользовательского доступа сервер SHOULD поддерживать локальный индекс: - -```text -blockchain_name -> arweave_tx_id + chunk_offset + chunk_size -``` - -Он указывает на последний известный `UserBlockchainChunk`. Backlink внутри chunk делает эту одну ссылку достаточной для обхода всей архивной истории конкретной blockchain. diff --git a/docs/Archive/02_IMPLEMENTATION_MAP.md b/docs/Archive/02_IMPLEMENTATION_MAP.md deleted file mode 100644 index e671af29..00000000 --- a/docs/Archive/02_IMPLEMENTATION_MAP.md +++ /dev/null @@ -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: - -`.<00001>..tmp.SHiNE-archive` - -После полной успешной загрузки transaction header + chunks в Arweave: - -`.<00001>...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-а. diff --git a/docs/Archive/03_DEPLOY_TEST_SERVER.md b/docs/Archive/03_DEPLOY_TEST_SERVER.md deleted file mode 100644 index 6b6248f8..00000000 --- a/docs/Archive/03_DEPLOY_TEST_SERVER.md +++ /dev/null @@ -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 -``` - -Если в проекте используется рабочий Anchor deploy workflow, допустимо использовать его вместо прямого `solana program deploy`; главное — сохранить тот же Program ID. - -После обновления: - -```bash -solana program show SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6 --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 '' -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 -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..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 = ''; -``` - -`archive_head_tx_id` и `archive_head_hash` должны быть непустыми. - -## Arweave - -TX берётся прямо из имени финального файла. Проверить: - -```bash -curl -sS 'https://arweave.net/tx//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= -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:///Blockchain-Viewer.html -``` - -В приложении: `Настройки → Архив блокчейна`. diff --git a/docs/Archive/04_TEST_AND_OPERATIONS.md b/docs/Archive/04_TEST_AND_OPERATIONS.md deleted file mode 100644 index da32de67..00000000 --- a/docs/Archive/04_TEST_AND_OPERATIONS.md +++ /dev/null @@ -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=''; -``` - -## Проверка второй публикации - -До следующей полуночи добавить несколько новых 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= -``` - -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. diff --git a/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md b/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md deleted file mode 100644 index fd2c6d5c..00000000 --- a/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md +++ /dev/null @@ -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. diff --git a/docs/Archive/06_FILE_MANIFEST.md b/docs/Archive/06_FILE_MANIFEST.md deleted file mode 100644 index 12662e6f..00000000 --- a/docs/Archive/06_FILE_MANIFEST.md +++ /dev/null @@ -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` - -## Удаления - -На этом этапе файлов для удаления нет. diff --git a/docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md b/docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md deleted file mode 100644 index 51275c52..00000000 --- a/docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md +++ /dev/null @@ -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= - &offset= - &size= - &blockchain= -``` - -Дополнительно: - -```text -&channel= -&message= -``` - -`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; -``` diff --git a/docs/Archive/README.md b/docs/Archive/README.md deleted file mode 100644 index edf5b61d..00000000 --- a/docs/Archive/README.md +++ /dev/null @@ -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 не меняется. -- Готовый файл сначала существует локально как `.<00001>..tmp.SHiNE-archive`. После успешной загрузки в Arweave он переименовывается в `.<00001>...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`. diff --git a/docs/Archive/archive-publisher.example.properties b/docs/Archive/archive-publisher.example.properties deleted file mode 100644 index dd046cf0..00000000 --- a/docs/Archive/archive-publisher.example.properties +++ /dev/null @@ -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 diff --git a/docs/Blockchain/01_Common_Block_Format.md b/docs/Blockchain/01_Common_Block_Format.md index a9fc5644..a9b49bb5 100644 --- a/docs/Blockchain/01_Common_Block_Format.md +++ b/docs/Blockchain/01_Common_Block_Format.md @@ -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 = +``` + +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. diff --git a/docs/Blockchain/17_ANS104_Arweave_Transport.md b/docs/Blockchain/17_ANS104_Arweave_Transport.md new file mode 100644 index 00000000..a71759a7 --- /dev/null +++ b/docs/Blockchain/17_ANS104_Arweave_Transport.md @@ -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= +``` + +Теги входят в 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. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index 9f1f6577..711cd046 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -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=`. +- 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 этим изменением не затронуты. diff --git a/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md b/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md new file mode 100644 index 00000000..c08e6016 --- /dev/null +++ b/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md @@ -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= +``` + +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=`. +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. diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index d00ba6c2..ac552eb6 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -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` сейчас пишет через `.tmp_bch`, `.write_check` и `.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= + -> 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. diff --git a/docs/Blockchain/sync-between-servers.md b/docs/Blockchain/sync-between-servers.md index c839e2b7..c1eff7e7 100644 --- a/docs/Blockchain/sync-between-servers.md +++ b/docs/Blockchain/sync-between-servers.md @@ -1,280 +1,42 @@ -# Синхронизация блокчейнов и доставка DM между серверами SHiNE +# Синхронизация blockchain между SHiNE-серверами -Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE. +## Локальная модель хранения -## 1. Зачем нужна синхронизация +PostgreSQL является единственным локальным хранилищем пользовательских блоков. Файлы `.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 `.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. Удаляются файлы `.bch` и `.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. собирается `.tmp_bch` как полный кандидат на замену основного файла; -2. пишется маленький sidecar `.write_check` с `blockNumber` и `blockHash`; -3. только после этого создаётся пустой marker `.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. diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md deleted file mode 100644 index c4a2db63..00000000 --- a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md +++ /dev/null @@ -1,1619 +0,0 @@ -# SHiNE Archive Protocol v1.0 - -## Полная спецификация серверной архивации SHiNE-блоков в Arweave и фиксации головы архива в Solana - -**Статус:** согласованная спецификация v1.0 -**Язык:** русский -**Magic:** `SHINE-ARCHIVE` -**Версия протокола:** `1.0` -**Endian:** big-endian для всех фиксированных целых чисел - ---- - -# 1. Назначение - -Этот документ описывает механизм, по которому SHiNE-сервер периодически: - -1. синхронизирует обычные SHiNE-блоки существующим механизмом; -2. определяет новые локальные блоки, ещё не попавшие в архив; -3. фиксирует неизменяемую дельту; -4. группирует все новые записи одной `blockchain_name` в один блок пользователя внутри большого архивного блока; -5. добавляет к каждому такому пользовательскому блоку одну ссылку на предыдущий пользовательский блок этой же `blockchain_name`; -6. формирует один большой бинарный блок `SHINE-ARCHIVE`; -7. загружает его в Arweave; -8. ждёт закрепления/подтверждения Arweave-транзакции; -9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; -10. только после Solana `finalized` переводит локальные курсоры на новую точку. - -На первом этапе предполагается один архивный server, но формат изначально не должен запрещать появление других архиваторов. - ---- - -# 2. Основные понятия - -## 2.1. Исходный SHiNE-блок / запись - -Это существующие в серверной БД оригинальные `block_bytes`, относящиеся к конкретной `blockchain_name`. - -Они уже содержат пользовательские данные и существующую криптографию SHiNE. - -Архиватор: - -- не изменяет их; -- не переподписывает их; -- не переводит их в Base64; -- не пересобирает их содержимое. - -## 2.2. `blockchain_name` - -В архивном формате основной идентификатор пользовательской цепочки — именно `blockchain_name`. - -Отдельно хранить `login` для пользовательского блока не требуется. - -`blockchain_name` уже однозначно идентифицирует конкретную пользовательскую blockchain и логически включает пользовательскую идентичность + номер/вариант цепочки. - -## 2.3. Большой архивный блок - -Один файл/объект `SHINE-ARCHIVE`, который сервер формирует за один цикл архивной публикации. - -Он содержит: - -- header; -- список предыдущих больших архивных блоков; -- по одному `UserBlockchainChunk` для каждой `blockchain_name`, у которой есть новые записи; -- footer; -- SHA-256 большого блока; -- подпись сервера, который этот большой блок сформировал и закрыл. - -Один большой архивный блок после публикации соответствует одной Arweave-транзакции. - -## 2.4. `UserBlockchainChunk` - -В одном большом архивном блоке для одной `blockchain_name` существует максимум один такой chunk. - -Если за текущую дельту у пользователя/цепочки накопилось 1, 3, 5 или 20 записей, все они помещаются внутрь одного `UserBlockchainChunk`. - -Навигационная ссылка одна на весь chunk. - ---- - -# 3. Главная модель пользовательской истории - -Допустим `alice-001` имела записи: - -```text -BigBlock 70: - AliceChunk = 2 записи - -BigBlock 81: - AliceChunk = 5 записей - -BigBlock 95: - AliceChunk = 3 записи - -BigBlock 100: - AliceChunk = 4 записи -``` - -Тогда история `alice-001` выглядит: - -```text -BigBlock 100 - AliceChunk - 4 записи - | - +--> PreviousBlockchainChunkRef - | - v -BigBlock 95 - AliceChunk - 3 записи - | - +--> PreviousBlockchainChunkRef - | - v -BigBlock 81 - AliceChunk - 5 записей - | - v -BigBlock 70 -``` - -Навигация идёт пачками записей одной blockchain, а не по одной записи за раз. - ---- - -# 4. Сервер сам добавляет навигацию - -Пользователь не обязан знать: - -- в какой большой архивный блок попадут его записи; -- какой будет Arweave TX ID; -- какие будут offsets; -- где находится предыдущий chunk. - -Все эти данные сервер добавляет при формировании большого блока. - -Исходные подписанные пользовательские `block_bytes` остаются неизменными. - -`PreviousBlockchainChunkRef` является внешней серверной архивной метаинформацией и защищается hash + подписью всего большого архивного блока. - ---- - -# 5. Необходимые ключи архивного сервера - -Архивный server хранит: - -## 5.1. SHiNE root private key - -Используется: - -- для подписания новой версии User PDA при обычном `update_user_pda`; -- в v1.0 — для подписи закрытого большого архивного блока. - -## 5.2. SHiNE client private key - -Используется как Solana transaction signer / fee payer в соответствии с текущей логикой `shine_users`. - -## 5.3. Arweave JWK / private wallet key - -Используется для загрузки и оплаты Arweave-транзакции. - -## 5.4. Blockchain private key - -Для архивной публикации не требуется. - -Архиватор не создаёт новые пользовательские blockchain-блоки. - ---- - -# 6. Расписание публикации - -Архивная публикация запускается один раз в сутки в заданное локальное время. Она НЕ использует интервал «каждые N минут». - -Пример: - -```properties -archive.publish.enabled=true -archive.publish.time=00:00 -archive.publish.zoneId= -``` - -Для v1.0 значение по умолчанию: - -```text -00:00 -``` - -То есть новый snapshot и новый большой архивный блок создаются один раз в сутки в полночь. - -Если `archive.publish.zoneId` пуст, используется системная timezone сервера. При необходимости её можно задать явно, например `Europe/Warsaw`. Это сохраняет публикацию ровно в указанное локальное время даже при переходах летнего/зимнего времени. - -Незавершённый archive job после рестарта не ждёт следующей полуночи: сервер продолжает именно его сразу. Новый snapshot при старте вне назначенного времени не создаётся. - -## 6.1. Первая архивная публикация - -Если у данного archive publisher ещё нет подтверждённых архивных курсоров, первая публикация берёт ВСЁ локально известное состояние: - -```text -для каждой blockchain_name: -source block 0 .. current local head -``` - -То есть первый большой архивный блок содержит все SHiNE-блоки, которые сервер успел узнать к моменту первого суточного snapshot. После успешной публикации следующие большие блоки содержат только дельту относительно подтверждённых курсоров. - -## 6.2. Локальная папка и имена файлов - -Перед любой сетевой загрузкой большой блок сначала полностью создаётся на локальном диске. По умолчанию каталог: - -```text -data/archive/ -``` - -До получения Arweave TX ID файл имеет временное имя: - -```text -.<00001>.<дд.мм.гг>.tmp.SHiNE-archive -``` - -Например: - -```text -archive01.00001.11.09.26.tmp.SHiNE-archive -``` - -Дата — реальная дата создания/freeze snapshot большого блока в timezone archive publisher-а. Номер имеет минимальную ширину 5 цифр. Пять цифр — форматирование, а не лимит: блок `100000` получает шестизначный номер. - -После успешной загрузки Arweave возвращает реальный TX ID. Сервер сначала надёжно сохраняет TX ID в БД, затем атомарно переименовывает тот же локальный файл в: - -```text -.<00001>.<дд.мм.гг>..SHiNE-archive -``` - -Например: - -```text -archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive -``` - -Поле `` — не слово `trx`, а настоящий Base64URL TX ID загруженного объекта в Arweave. Финальный файл остаётся локально как постоянная копия. - -Crash recovery обязан продолжать работу с этим же файлом. Если TX ID уже сохранён, но процесс упал до rename, при следующем запуске сервер вычисляет финальное имя из сохранённого TX ID и завершает переименование без повторной сборки дельты. - ---- - -# 7. Состояние, относительно которого считается дельта - -Нужно различать: - -1. уже окончательно опубликованное состояние; -2. текущий замороженный job, который ещё проходит Arweave/Solana. - -Нельзя считать дельту от живой головы во время уже запущенной публикации. - ---- - -# 8. Таблица `archive_chain_cursor` - -Хранит окончательно подтверждённое архивное состояние для каждой `blockchain_name`. - -```sql -CREATE TABLE 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, - last_archive_big_block_hash BYTEA, - - last_chunk_offset BIGINT, - last_chunk_size BIGINT, - - updated_at_ms BIGINT NOT NULL -); -``` - -Назначение: - -- `last_archived_source_block_number` — до какого исходного SHiNE-блока этой blockchain всё успешно опубликовано; -- `last_archived_source_block_hash` — hash этого исходного блока; -- `last_archive_big_block_number` и `last_archive_big_block_hash` — в каком большом `SHINE-ARCHIVE` находится последний chunk этой blockchain; -- `last_chunk_offset` и `last_chunk_size` — точное положение последнего chunk внутри большого блока. - -Этого достаточно, чтобы следующая публикация создала один backlink на предыдущий chunk. - ---- - -# 9. Отдельный индекс по каждой записи не нужен - -Поскольку все новые записи одной `blockchain_name` агрегируются в один `UserBlockchainChunk`, не нужно хранить offsets каждой отдельной записи в истории. - -Для следующей публикации достаточно помнить: - -```text -предыдущий big block -предыдущий big block hash -chunk offset -chunk size -``` - ---- - -# 10. Таблица `archive_publish_job` - -Хранит crash-safe состояние одной архивной публикации. - -```sql -CREATE TABLE 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 -); -``` - -Рекомендуемые статусы: - -```text -SNAPSHOT_CREATED -FILE_BUILT -ARWEAVE_UPLOADED -ARWEAVE_CONFIRMED -SOLANA_SUBMITTED -SOLANA_FINALIZED -CURSORS_COMMITTED -FAILED -``` - -Завершённые rows этой таблицы одновременно дают локальную историю больших блоков: - -```text -big_block_number -archive_hash -arweave_tx_id -``` - -По ним строится FULL-список предыдущих больших блоков следующей публикации. - ---- - -# 11. Таблица `archive_publish_job_chain` - -Хранит frozen range каждой blockchain в текущем job и координаты создаваемого chunk. - -```sql -CREATE TABLE archive_publish_job_chain ( - job_id BIGINT NOT NULL, - 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) -); -``` - ---- - -# 12. Заморозка дельты - -Пример: - -```text -локально: -alice-001 = source block 180 -bob-001 = source block 94 - -опубликовано: -alice-001 = 160 -bob-001 = 90 -``` - -Новый job фиксирует: - -```text -alice-001: 161..180 -bob-001: 91..94 -``` - -Если во время загрузки появятся: - -```text -alice-001 181..185 -bob-001 95..97 -``` - -они попадут только в следующий job. - ---- - -# 13. Проверка курсора перед публикацией - -Перед созданием frozen range сервер проверяет: - -```text -local hash(last_archived_source_block_number) -== -archive_chain_cursor.last_archived_source_block_hash -``` - -При несовпадении публикация этой chain останавливается до resync/fork handling. - ---- - -# 14. Ограничение размера большого блока - -Offsets и sizes внутри `SHINE-ARCHIVE v1.0` используют `u32`. - -Поэтому один большой archive-файл MUST быть меньше `4 GiB`. - -Рекомендуемый запас: - -```properties -archive.maxFileBytes=4000000000 -``` - -Если собранный frozen job превышает этот лимит, v1.0 останавливает публикацию с явной ошибкой `ArchiveTooLargeException` и не двигает курсоры. Практически лимит очень велик; для такого сервера следует уменьшить объём данных между суточными закрытиями или реализовать деление snapshot на несколько big blocks. Автоматическое деление одного snapshot на несколько big blocks оставлено как совместимое будущее расширение. - ---- - -# 15. Magic и версия - -Файл начинается ASCII: - -```text -SHINE-ARCHIVE -``` - -Размер: 13 байт. - -Далее: - -```text -u8 version_major -u8 version_minor -``` - -Для v1.0: - -```text -1 -0 -``` - ---- - -# 16. Общий layout большого блока - -```text -+------------------------------------------+ -| FIXED HEADER | -+------------------------------------------+ -| creator_login | -+------------------------------------------+ -| PREVIOUS BIG BLOCK REFERENCES | -| ref[0] | -| ref[1] | -| ... | -+------------------------------------------+ -| UserBlockchainChunk #1 | -| UserBlockchainChunk #2 | -| ... | -+------------------------------------------+ -| FOOTER: closer_login | -| block_hash | -| signature | -+------------------------------------------+ -``` - ---- - -# 17. Fixed Header v1.0 - -Все integers — big-endian. - -| Поле | Тип | Размер | -|---|---:|---:| -| magic | bytes[13] | 13 | -| version_major | u8 | 1 | -| version_minor | u8 | 1 | -| header_size | u32 | 4 | -| big_block_number | u32 | 4 | -| created_at_ms | u64 | 8 | -| creator_login_length | u8 | 1 | -| references_mode | u8 | 1 | -| references_count | u32 | 4 | -| reference_entry_size | u16 | 2 | -| parent_reference_index | u32 | 4 | -| blockchain_chunks_count | u32 | 4 | -| total_user_records_count | u32 | 4 | - -После fixed header немедленно идут `creator_login` bytes. - -`header_size` — byte offset первого `PreviousBigBlockReference`. - ---- - -# 18. `creator_login` - -SHiNE login пользователя/сервера, который сформировал большой блок. - -```text -u8 creator_login_length -bytes[N] creator_login UTF-8 -``` - -Этот login находится в начале файла, поэтому его можно узнать без скачивания payload. - ---- - -# 19. `big_block_number` - -```text -u32 -``` - -Рекомендуемая нумерация опубликованных больших блоков: - -```text -first = 1 -next = 2 -next = 3 -... -``` - -Первый реально публикуемый большой блок имеет номер `1`, поэтому его локальное имя содержит `00001`. Неудачная незавершённая попытка не становится частью опубликованной archive-цепочки. - ---- - -# 20. `created_at_ms` - -```text -u64 -``` - -Unix Epoch UTC milliseconds. - -Это время заморозки содержимого big block. - ---- - -# 21. Режим ссылок на предыдущие большие блоки - -```text -references_mode u8 -``` - -Значения: - -```text -0 = FULL -1 = PARTIAL, зарезервировано на будущее -``` - -Writer v1.0 MUST использовать `FULL`. - ---- - -# 22. FULL previous-block table - -Новый big block содержит ссылки на ВСЕ предыдущие большие блоки своей ветки. - -Пример: - -```text -BigBlock #365 - -References: -#1 -#2 -#3 -... -#364 -``` - -При одном big block в день через год таблица занимает примерно 24.8 KiB. - ---- - -# 23. `PreviousBigBlockReference` - -Фиксированный размер: 68 байт. - -| Поле | Тип | Размер | -|---|---:|---:| -| big_block_number | u32 | 4 | -| big_block_hash | bytes[32] | 32 | -| arweave_tx_id | bytes[32] | 32 | - -Семантика: - -```text -big_block_number = логический номер -big_block_hash = криптографическая идентичность -arweave_tx_id = где скачать блок -``` - ---- - -# 24. `references_count` - -Количество entries в таблице ссылок. - -```text -u32 -``` - ---- - -# 25. `reference_entry_size` - -```text -u16 -``` - -Для v1.0: - -```text -68 -``` - -Поле оставлено для будущего расширения entry. - ---- - -# 26. `parent_reference_index` - -```text -u32 -``` - -Индекс непосредственного родителя внутри reference table. - -Для первого большого блока (`#1`), у которого нет родителя: - -```text -0xFFFFFFFF -``` - -В простой FULL-линейной ветке обычно это последний entry. - ---- - -# 27. Быстрое скачивание начала блока - -Клиент сначала скачивает fixed header + creator login. - -Из него узнаёт: - -```text -header_size -references_count -reference_entry_size -``` - -Затем скачивает только reference table и уже имеет адреса/hash всех предыдущих больших блоков. - -Payload пользователей можно пока не скачивать. - ---- - -# 28. `blockchain_chunks_count` - -```text -u32 -``` - -Количество `UserBlockchainChunk` в текущем big block. - -Одна `blockchain_name` встречается максимум один раз. - ---- - -# 29. `total_user_records_count` - -```text -u32 -``` - -Общее количество исходных SHiNE-записей во всех chunks. - ---- - -# 30. Порядок chunks и records - -Для детерминированной сериализации chunks SHOULD сортироваться по `blockchain_name ASC`. - -Внутри chunk исходные записи MUST идти по возрастанию номера исходного SHiNE-блока. - ---- - -# 31. Формат `UserBlockchainChunk` - -```text -u32 chunk_size - -u8 blockchain_name_length -bytes[N] blockchain_name UTF-8 - -u32 records_count - -repeat records_count: - u32 record_size - bytes[M] raw_record_bytes - -u32 previous_big_block_ref -u32 previous_chunk_offset -u32 previous_chunk_size -``` - ---- - -# 32. `chunk_size` - -```text -u32 -``` - -Полный размер chunk от первого байта `chunk_size` до последнего байта `previous_chunk_size` включительно. - -Это позволяет: - -- быстро пропустить chunk; -- скачать его Range-запросом; -- сохранить его точные координаты для следующего backlink. - ---- - -# 33. `blockchain_name` внутри chunk - -Хранится только `blockchain_name`. - -Отдельный `login` не нужен. - ---- - -# 34. `records_count` - -```text -u32 -``` - -Если за период у `alice-001` появилось 7 записей: - -```text -records_count = 7 -``` - -Все 7 находятся в одном chunk. - ---- - -# 35. Raw records - -Каждая исходная запись сериализуется: - -```text -u32 record_size -bytes[record_size] raw_record_bytes -``` - -`raw_record_bytes` — оригинальные данные SHiNE из БД. - -Сервер не меняет их содержимое. - ---- - -# 36. Один backlink на весь chunk - -После всех raw records находится ровно один `PreviousBlockchainChunkRef`. - -Он относится ко всей пачке записей blockchain. - -Он не повторяется у отдельных записей. - ---- - -# 37. `PreviousBlockchainChunkRef` - -| Поле | Тип | Размер | -|---|---:|---:| -| previous_big_block_ref | u32 | 4 | -| previous_chunk_offset | u32 | 4 | -| previous_chunk_size | u32 | 4 | - -Итого 12 байт на весь chunk. - ---- - -# 38. `previous_big_block_ref` - -Индекс в `PreviousBigBlockReferences[]` текущего big block. - -Он указывает на тот предыдущий big block, где находится предыдущий chunk этой же `blockchain_name`. - -Это именно reference index, а не номер блока. - ---- - -# 39. `previous_chunk_offset` - -```text -u32 -``` - -Byte offset от начала предыдущего `SHINE-ARCHIVE` до первого байта предыдущего chunk этой blockchain. - -Первый байт chunk — поле `chunk_size`. - ---- - -# 40. `previous_chunk_size` - -```text -u32 -``` - -Полный размер предыдущего chunk. - -Поэтому клиент может Range-запросом забрать сразу всю предыдущую пачку записей пользователя. - ---- - -# 41. Первая публикация blockchain - -Если у данной `blockchain_name` нет предыдущего архивного chunk: - -```text -previous_big_block_ref = 0xFFFFFFFF -previous_chunk_offset = 0 -previous_chunk_size = 0 -``` - ---- - -# 42. Пример backlink-цепочки - -В `BigBlock #100` есть `alice-001` chunk из 4 записей. - -Его backlink: - -```text -previous_big_block_ref = 94 -previous_chunk_offset = 8123 -previous_chunk_size = 1770 -``` - -`ref[94]` содержит: - -```text -big_block_number = 95 -big_block_hash = ... -arweave_tx_id = ... -``` - -Клиент: - -1. получает TX big block #95; -2. проверяет hash; -3. делает Range на `8123 .. 8123+1770`; -4. получает весь предыдущий `alice-001` chunk; -5. читает его записи; -6. берёт следующий backlink. - ---- - -# 43. Почему TX ID не хранится в каждом chunk - -TX ID и hash предыдущих больших блоков уже один раз записаны в общей reference table. - -Поэтому в chunk достаточно компактного `u32 previous_big_block_ref`. - ---- - -# 44. Footer большого блока - -После последнего chunk: - -```text -u8 closer_login_length -bytes[N] closer_login UTF-8 - -bytes[32] block_hash -bytes[64] closer_signature -``` - ---- - -# 45. `closer_login` - -Login пользователя/сервера, который сформировал и закрыл big block. - -В v1.0: - -```text -closer_login MUST == creator_login -``` - -Login повторяется намеренно: - -- header даёт автора сразу; -- footer явно фиксирует того, кто закрыл блок; -- hash покрывает footer login; -- verifier проверяет совпадение. - ---- - -# 46. `block_hash` - -```text -bytes[32] -SHA-256 -``` - -Хэшируется всё от первого байта magic до последнего байта `closer_login` включительно. - -Не входят: - -```text -block_hash -closer_signature -``` - -Псевдокод: - -```text -hash_input = - fixed_header - + creator_login - + references - + all UserBlockchainChunks - + closer_login_length - + closer_login - -block_hash = SHA256(hash_input) -``` - ---- - -# 47. `closer_signature` - -```text -bytes[64] -Ed25519 -``` - -Подписывается: - -```text -ASCII("SHINE-ARCHIVE-V1") || block_hash -``` - -Для v1.0 используется root private key аккаунта `creator_login`. - ---- - -# 48. Проверка подписи - -Verifier: - -1. читает `creator_login`; -2. проверяет `creator_login == closer_login`; -3. получает root public key аккаунта из User PDA / исторического состояния Solana; -4. пересчитывает `block_hash`; -5. проверяет Ed25519 signature. - -Старые архивы должны оставаться проверяемыми после ротации root key, поэтому исторические PDA-состояния нужно сохранять/уметь получать. - ---- - -# 49. Собственный Arweave TX ID текущего блока - -Текущий файл не может заранее знать свой будущий TX ID. - -Поэтому собственный TX ID: - -- не входит в сам текущий файл; -- сохраняется после upload; -- записывается в Solana PDA; -- появляется как previous-block reference уже в следующем big block. - ---- - -# 50. User PDA block type `100` - -Archive head хранится прямо в существующем User PDA publisher-а. - -Формат: - -```text -u8 block_type = 100 -u8 block_version = 0 -bytes[32] archive_tx_id -bytes[32] archive_hash -``` - -Итого 66 байт. - -`archive_hash` = `block_hash` последнего успешно опубликованного big block. - ---- - -# 51. Обновление Solana - -Отдельная новая Solana instruction не требуется. - -Используется существующий `update_user_pda`. - -Нужно расширить существующие serializer/parser/codec так, чтобы block type 100: - -- читался; -- записывался; -- сохранялся обычными обновлениями; -- мог быть изменён архивным сервером обычным update User PDA. - ---- - -# 52. Полный порядок публикации - -```text -1. Существующая server-to-server sync независимо обновляет локальные blockchains. - -2. Scheduler запускает archive job. - -3. Проверяется отсутствие другого активного archive job. - -4. Читаются archive_chain_cursor. - -5. Читаются текущие локальные blockchain heads. - -6. Для каждой blockchain вычисляется frozen delta. - -7. Создаётся archive_publish_job. - -8. Создаются archive_publish_job_chain rows. - -9. Загружается история всех успешно finalized previous big blocks. - -10. Формируется fixed header. - -11. Записывается creator_login. - -12. Записывается FULL PreviousBigBlockReferences. - -13. Для каждой blockchain_name: - - читается frozen range; - - создаётся ровно один UserBlockchainChunk; - - внутрь кладутся все новые records; - - добавляется один PreviousBlockchainChunkRef; - - сохраняются new_chunk_offset/new_chunk_size. - -14. Записывается closer_login. - -15. Вычисляется SHA-256 block_hash. - -16. Создаётся Ed25519 closer_signature. - -17. Файл закрывается. - -18. Файл загружается в Arweave. - -19. TX ID сохраняется в archive_publish_job. - -20. Сервер ждёт требуемое число Arweave confirmations. - -21. Обычным update_user_pda обновляется block type 100: - archive_tx_id - archive_hash - -22. Сервер ждёт Solana finalized. - -23. В одной DB transaction обновляются archive_chain_cursor. - -24. Job получает CURSORS_COMMITTED. -``` - ---- - -# 53. Cursor commit - -Для каждой blockchain из job: - -```text -last_archived_source_block_number = to_source_block_number -last_archived_source_block_hash = last_source_block_hash - -last_archive_big_block_number = current big_block_number -last_archive_big_block_hash = current block_hash - -last_chunk_offset = new_chunk_offset -last_chunk_size = new_chunk_size -``` - -Следующая публикация сразу знает, куда должен вести backlink. - ---- - -# 54. Arweave service - -Старый `TestFreeAvatarArweaveService` удаляется из активного протокола; archive publisher использует отдельный `ArweaveArchiveService`. - -Его следует переделать/переименовать, например в: - -```text -ArweaveArchiveService -``` - -Он должен: - -- использовать Arweave JWK; -- подписывать и оплачивать upload; -- поддерживать большие/chunked uploads; -- возвращать TX ID; -- ждать подтверждения; -- опрашивать confirmations. - -Рекомендуемые настройки: - -```properties -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 -``` - ---- - -# 55. Solana finalization - -После обычного `update_user_pda` сервер должен дождаться: - -```text -finalized -``` - -Только после этого разрешается сдвинуть локальные курсоры. - ---- - -# 56. Crash recovery - -## После `SNAPSHOT_CREATED` - -Возобновить тот же frozen job. - -## После `FILE_BUILT` - -Использовать тот же локальный файл. - -## После `ARWEAVE_UPLOADED` - -Не загружать второй раз. Продолжить по сохранённому TX ID. - -## После `ARWEAVE_CONFIRMED` - -Продолжить Solana update. - -## После `SOLANA_SUBMITTED` - -Сначала проверить transaction / текущий PDA. - -## После `SOLANA_FINALIZED`, но до cursor commit - -Проверить: - -```text -PDA.archive_tx_id == expected_tx -PDA.archive_hash == expected_hash -``` - -После этого безопасно выполнить cursor commit. - ---- - -# 57. Нет новых данных - -Если новых записей нет ни в одной blockchain: - -```text -не создавать пустой SHINE-ARCHIVE -не загружать Arweave -не обновлять Solana -``` - ---- - -# 58. FULL references как стартовая стратегия - -v1.0 сознательно хранит ВСЕ previous big blocks в каждом новом big block. - -Плюсы: - -- актуальный блок сразу даёт карту всей предыдущей истории; -- не нужно идти parent-by-parent; -- backlink может использовать компактный ref index; -- reader очень простой. - -При одном big block в сутки: - -```text -1 год: ~24.8 KiB -5 лет: ~121 KiB -20 лет: ~485 KiB -``` - ---- - -# 59. PARTIAL references - -`references_mode = 1` зарезервирован на будущее. - -v1.0 writer его не реализует. - -В будущем PARTIAL может означать: - -- только реально используемые previous blocks; -- последние N blocks; -- checkpoint references; -- смешанную стратегию. - ---- - -# 60. Чтение истории одной blockchain - -Имея актуальный chunk `alice-001`, клиент: - -1. читает все records chunk; -2. читает `PreviousBlockchainChunkRef`; -3. если ref = `0xFFFFFFFF`, история закончилась; -4. иначе берёт соответствующий `PreviousBigBlockReference`; -5. получает TX ID + hash previous big block; -6. Range-запросом читает `previous_chunk_offset/previous_chunk_size`; -7. получает сразу всю предыдущую пачку записей `alice-001`; -8. повторяет. - -Так можно читать историю конкретной blockchain, не скачивая чужие chunks. - ---- - -# 61. Дубликаты - -Если исходная SHiNE-запись уже есть у получателя, она пропускается/дедуплицируется стандартной логикой. - ---- - -# 62. Конфликты исходной blockchain - -Архивная ветка сама по себе не является пользовательским конфликтом. - -Конфликт возникает, когда одна `blockchain_name` имеет два несовместимых валидно подписанных продолжения одной предыдущей точки. - -Для v1 безопасно: - -```text -сохранить обе ветки -пометить blockchain CONFLICTED -остановить автоматическое продолжение -``` - -Механизм разрешения fork можно добавить позже. - ---- - -# 63. Авторство большого блока - -Автор указан дважды: - -## Header - -```text -creator_login -``` - -## Footer - -```text -closer_login -block_hash -closer_signature -``` - -Для v1.0: - -```text -creator_login == closer_login -``` - -Это означает: именно этот SHiNE-аккаунт сформировал и криптографически закрыл big block. - ---- - -# 64. Подпись сервера не заменяет подписи пользователей - -`closer_signature` доказывает авторство большого archive block. - -Каждая пользовательская raw record всё равно проверяется существующей криптографией SHiNE. - ---- - -# 65. Независимость от Arweave - -Arweave TX ID — location. - -SHA-256 — identity. - -Поэтому архивы можно перенести на другой storage и всё равно проверять. - ---- - -# 66. Block type 100 в Solana — текущая голова - -User PDA publisher-а хранит только: - -```text -current archive TX ID -current archive SHA-256 -``` - -Детальная история находится внутри big blocks. - ---- - -# 67. Startup preflight - -Перед запуском archive publisher сервер SHOULD проверить: - -```text -derivePublic(root_private) == UserPDA.root_public_key -``` - -и: - -```text -derivePublic(client_private) == UserPDA.client_key -``` - -При несовпадении archive publisher не запускается. - ---- - -# 68. Рекомендуемые настройки - -```properties -archive.publish.enabled=false -archive.publish.time=00:00 -archive.publish.zoneId= -archive.workDir=data/archive - -archive.maxFileBytes=4000000000 - -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 - -archive.solana.rootKeyPath=/opt/shine/secrets/root.key -archive.solana.clientKeyPath=/opt/shine/secrets/client.key -archive.solana.commitment=finalized - -# Отдельного archive.solana.rpcUrl нет. -# Используется solana.users.sync.rpcUrl, а если он пуст — обычный solana.rpcUrl. -``` - ---- - -# 69. Требование к `AppConfig` - -Archive settings должны читаться из: - -- Java system properties; -- environment variables; -- `application.properties`. - -`getInt()` и `getBoolean()` должны использовать общую логику `getParam()`. - ---- - -# 70. Что переиспользуется из текущего сервера - -Существующие механизмы: - -```text -BlockchainStateDAO.listAll() -BlocksDAO.listRangeByNumber(...) -``` - -дают необходимые heads и raw block ranges. - -Используются существующие: - -```text -block_bytes -block_hash -block_signature -blockchain_name -block_number -``` - -Server-to-server sync в v1.0 не меняется. - ---- - -# 71. Блокировки - -Можно использовать существующий blockchain lock только на коротком этапе: - -```text -проверить cursor hash -прочитать frozen range -проверить конечный hash -сохранить/сериализовать frozen data -``` - -Lock не удерживается во время Arweave/Solana ожиданий. - ---- - -# 72. Реализационный checklist - -## Database - -- [x] `archive_chain_cursor` -- [x] `archive_publish_job` -- [x] `archive_publish_job_chain` -- [x] schema migration -- [x] crash recovery - -## Archive writer - -- [x] magic `SHINE-ARCHIVE` -- [x] major/minor version -- [x] fixed header -- [x] creator login -- [x] FULL previous big block table -- [x] one chunk per `blockchain_name` -- [x] many raw records inside one chunk -- [x] one backlink per chunk -- [x] closer login -- [x] SHA-256 -- [x] Ed25519 signature -- [x] max file size < 4 GiB -- [x] local `.tmp.SHiNE-archive -> ..SHiNE-archive` lifecycle in `data/archive` - -## Arweave - -- [x] rename/refactor `TestFreeAvatarArweaveService` -- [x] remove avatar-specific logic -- [x] large/chunked upload -- [x] confirmation polling - -## Solana - -- [x] add PDA block type `100` -- [x] update Rust codec -- [x] update Java codec -- [x] update JS codec/UI writer -- [x] use ordinary `update_user_pda` -- [x] server-side transaction writer -- [x] root signature -- [x] client fee payer -- [x] wait for `finalized` - -## Scheduler - -- [x] `archive.publish.enabled` -- [x] `archive.publish.time` -- [x] `archive.publish.zoneId` -- [x] no concurrent jobs -- [ ] future: автоматическое split > max size (v1.0 сейчас безопасно останавливается без cursor commit) -- [x] skip when no new blocks - ---- - -# 73. Итоговая бинарная схема v1.0 - -```text -SHINE-ARCHIVE -version_major u8 -version_minor u8 - -header_size u32 -big_block_number u32 -created_at_ms u64 - -creator_login_length u8 - -references_mode u8 -references_count u32 -reference_entry_size u16 -parent_reference_index u32 - -blockchain_chunks_count u32 -total_user_records_count u32 - -creator_login bytes - - -PreviousBigBlockReference[references_count]: - - big_block_number u32 - big_block_hash [32] - arweave_tx_id [32] - - -UserBlockchainChunk[blockchain_chunks_count]: - - chunk_size u32 - - blockchain_name_length u8 - blockchain_name bytes - - records_count u32 - - repeat records_count: - record_size u32 - raw_record_bytes - - previous_big_block_ref u32 - previous_chunk_offset u32 - previous_chunk_size u32 - - -FOOTER: - - closer_login_length u8 - closer_login bytes - - block_hash [32] - closer_signature [64] -``` - ---- - -# 74. Основная архитектура одним рисунком - -```text -SOLANA USER PDA - | - | block type 100 - | TX + HASH - v -BigBlock #100 - | - +-- Header - | creator = server-A - | - +-- References - | ref[0] -> BigBlock #1 / hash / TX - | ref[1] -> BigBlock #2 / hash / TX - | ... - | ref[98] -> BigBlock #99 / hash / TX - | - +-- alice-001 chunk - | records x4 - | | - | +--> BigBlock #95 / AliceChunk - | - +-- bob-001 chunk - | records x2 - | | - | +--> BigBlock #98 / BobChunk - | - +-- kate-002 chunk - | records x10 - | | - | +--> BigBlock #74 / KateChunk - | - +-- Footer - closer = server-A - SHA-256 - Ed25519 signature -``` - ---- - -# 75. Итоговые решения v1.0 - -- один большой блок = одна архивная публикация; -- в начале лежит полный список всех предыдущих больших блоков; -- каждый previous block reference содержит `number + SHA-256 + Arweave TX ID`; -- одна `blockchain_name` встречается в большом блоке максимум одним chunk; -- все новые записи этой blockchain объединяются в этот chunk; -- chunk имеет одну ссылку на предыдущий chunk этой же blockchain; -- backlink состоит из `previous big block ref + chunk offset + chunk size`; -- сервер сам создаёт backlink; -- пользовательские raw records не меняются; -- login сервера есть в header и footer; -- большой блок закрывается SHA-256 + Ed25519 signature сервера; -- Solana User PDA block `100` хранит текущий TX ID + archive hash; -- PDA обновляется обычным `update_user_pda`; -- Arweave используется как долговременное хранилище; -- публикация считается завершённой только после Arweave confirmation + Solana finalized + cursor commit. diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md deleted file mode 100644 index c4a2db63..00000000 --- a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md +++ /dev/null @@ -1,1619 +0,0 @@ -# SHiNE Archive Protocol v1.0 - -## Полная спецификация серверной архивации SHiNE-блоков в Arweave и фиксации головы архива в Solana - -**Статус:** согласованная спецификация v1.0 -**Язык:** русский -**Magic:** `SHINE-ARCHIVE` -**Версия протокола:** `1.0` -**Endian:** big-endian для всех фиксированных целых чисел - ---- - -# 1. Назначение - -Этот документ описывает механизм, по которому SHiNE-сервер периодически: - -1. синхронизирует обычные SHiNE-блоки существующим механизмом; -2. определяет новые локальные блоки, ещё не попавшие в архив; -3. фиксирует неизменяемую дельту; -4. группирует все новые записи одной `blockchain_name` в один блок пользователя внутри большого архивного блока; -5. добавляет к каждому такому пользовательскому блоку одну ссылку на предыдущий пользовательский блок этой же `blockchain_name`; -6. формирует один большой бинарный блок `SHINE-ARCHIVE`; -7. загружает его в Arweave; -8. ждёт закрепления/подтверждения Arweave-транзакции; -9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; -10. только после Solana `finalized` переводит локальные курсоры на новую точку. - -На первом этапе предполагается один архивный server, но формат изначально не должен запрещать появление других архиваторов. - ---- - -# 2. Основные понятия - -## 2.1. Исходный SHiNE-блок / запись - -Это существующие в серверной БД оригинальные `block_bytes`, относящиеся к конкретной `blockchain_name`. - -Они уже содержат пользовательские данные и существующую криптографию SHiNE. - -Архиватор: - -- не изменяет их; -- не переподписывает их; -- не переводит их в Base64; -- не пересобирает их содержимое. - -## 2.2. `blockchain_name` - -В архивном формате основной идентификатор пользовательской цепочки — именно `blockchain_name`. - -Отдельно хранить `login` для пользовательского блока не требуется. - -`blockchain_name` уже однозначно идентифицирует конкретную пользовательскую blockchain и логически включает пользовательскую идентичность + номер/вариант цепочки. - -## 2.3. Большой архивный блок - -Один файл/объект `SHINE-ARCHIVE`, который сервер формирует за один цикл архивной публикации. - -Он содержит: - -- header; -- список предыдущих больших архивных блоков; -- по одному `UserBlockchainChunk` для каждой `blockchain_name`, у которой есть новые записи; -- footer; -- SHA-256 большого блока; -- подпись сервера, который этот большой блок сформировал и закрыл. - -Один большой архивный блок после публикации соответствует одной Arweave-транзакции. - -## 2.4. `UserBlockchainChunk` - -В одном большом архивном блоке для одной `blockchain_name` существует максимум один такой chunk. - -Если за текущую дельту у пользователя/цепочки накопилось 1, 3, 5 или 20 записей, все они помещаются внутрь одного `UserBlockchainChunk`. - -Навигационная ссылка одна на весь chunk. - ---- - -# 3. Главная модель пользовательской истории - -Допустим `alice-001` имела записи: - -```text -BigBlock 70: - AliceChunk = 2 записи - -BigBlock 81: - AliceChunk = 5 записей - -BigBlock 95: - AliceChunk = 3 записи - -BigBlock 100: - AliceChunk = 4 записи -``` - -Тогда история `alice-001` выглядит: - -```text -BigBlock 100 - AliceChunk - 4 записи - | - +--> PreviousBlockchainChunkRef - | - v -BigBlock 95 - AliceChunk - 3 записи - | - +--> PreviousBlockchainChunkRef - | - v -BigBlock 81 - AliceChunk - 5 записей - | - v -BigBlock 70 -``` - -Навигация идёт пачками записей одной blockchain, а не по одной записи за раз. - ---- - -# 4. Сервер сам добавляет навигацию - -Пользователь не обязан знать: - -- в какой большой архивный блок попадут его записи; -- какой будет Arweave TX ID; -- какие будут offsets; -- где находится предыдущий chunk. - -Все эти данные сервер добавляет при формировании большого блока. - -Исходные подписанные пользовательские `block_bytes` остаются неизменными. - -`PreviousBlockchainChunkRef` является внешней серверной архивной метаинформацией и защищается hash + подписью всего большого архивного блока. - ---- - -# 5. Необходимые ключи архивного сервера - -Архивный server хранит: - -## 5.1. SHiNE root private key - -Используется: - -- для подписания новой версии User PDA при обычном `update_user_pda`; -- в v1.0 — для подписи закрытого большого архивного блока. - -## 5.2. SHiNE client private key - -Используется как Solana transaction signer / fee payer в соответствии с текущей логикой `shine_users`. - -## 5.3. Arweave JWK / private wallet key - -Используется для загрузки и оплаты Arweave-транзакции. - -## 5.4. Blockchain private key - -Для архивной публикации не требуется. - -Архиватор не создаёт новые пользовательские blockchain-блоки. - ---- - -# 6. Расписание публикации - -Архивная публикация запускается один раз в сутки в заданное локальное время. Она НЕ использует интервал «каждые N минут». - -Пример: - -```properties -archive.publish.enabled=true -archive.publish.time=00:00 -archive.publish.zoneId= -``` - -Для v1.0 значение по умолчанию: - -```text -00:00 -``` - -То есть новый snapshot и новый большой архивный блок создаются один раз в сутки в полночь. - -Если `archive.publish.zoneId` пуст, используется системная timezone сервера. При необходимости её можно задать явно, например `Europe/Warsaw`. Это сохраняет публикацию ровно в указанное локальное время даже при переходах летнего/зимнего времени. - -Незавершённый archive job после рестарта не ждёт следующей полуночи: сервер продолжает именно его сразу. Новый snapshot при старте вне назначенного времени не создаётся. - -## 6.1. Первая архивная публикация - -Если у данного archive publisher ещё нет подтверждённых архивных курсоров, первая публикация берёт ВСЁ локально известное состояние: - -```text -для каждой blockchain_name: -source block 0 .. current local head -``` - -То есть первый большой архивный блок содержит все SHiNE-блоки, которые сервер успел узнать к моменту первого суточного snapshot. После успешной публикации следующие большие блоки содержат только дельту относительно подтверждённых курсоров. - -## 6.2. Локальная папка и имена файлов - -Перед любой сетевой загрузкой большой блок сначала полностью создаётся на локальном диске. По умолчанию каталог: - -```text -data/archive/ -``` - -До получения Arweave TX ID файл имеет временное имя: - -```text -.<00001>.<дд.мм.гг>.tmp.SHiNE-archive -``` - -Например: - -```text -archive01.00001.11.09.26.tmp.SHiNE-archive -``` - -Дата — реальная дата создания/freeze snapshot большого блока в timezone archive publisher-а. Номер имеет минимальную ширину 5 цифр. Пять цифр — форматирование, а не лимит: блок `100000` получает шестизначный номер. - -После успешной загрузки Arweave возвращает реальный TX ID. Сервер сначала надёжно сохраняет TX ID в БД, затем атомарно переименовывает тот же локальный файл в: - -```text -.<00001>.<дд.мм.гг>..SHiNE-archive -``` - -Например: - -```text -archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive -``` - -Поле `` — не слово `trx`, а настоящий Base64URL TX ID загруженного объекта в Arweave. Финальный файл остаётся локально как постоянная копия. - -Crash recovery обязан продолжать работу с этим же файлом. Если TX ID уже сохранён, но процесс упал до rename, при следующем запуске сервер вычисляет финальное имя из сохранённого TX ID и завершает переименование без повторной сборки дельты. - ---- - -# 7. Состояние, относительно которого считается дельта - -Нужно различать: - -1. уже окончательно опубликованное состояние; -2. текущий замороженный job, который ещё проходит Arweave/Solana. - -Нельзя считать дельту от живой головы во время уже запущенной публикации. - ---- - -# 8. Таблица `archive_chain_cursor` - -Хранит окончательно подтверждённое архивное состояние для каждой `blockchain_name`. - -```sql -CREATE TABLE 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, - last_archive_big_block_hash BYTEA, - - last_chunk_offset BIGINT, - last_chunk_size BIGINT, - - updated_at_ms BIGINT NOT NULL -); -``` - -Назначение: - -- `last_archived_source_block_number` — до какого исходного SHiNE-блока этой blockchain всё успешно опубликовано; -- `last_archived_source_block_hash` — hash этого исходного блока; -- `last_archive_big_block_number` и `last_archive_big_block_hash` — в каком большом `SHINE-ARCHIVE` находится последний chunk этой blockchain; -- `last_chunk_offset` и `last_chunk_size` — точное положение последнего chunk внутри большого блока. - -Этого достаточно, чтобы следующая публикация создала один backlink на предыдущий chunk. - ---- - -# 9. Отдельный индекс по каждой записи не нужен - -Поскольку все новые записи одной `blockchain_name` агрегируются в один `UserBlockchainChunk`, не нужно хранить offsets каждой отдельной записи в истории. - -Для следующей публикации достаточно помнить: - -```text -предыдущий big block -предыдущий big block hash -chunk offset -chunk size -``` - ---- - -# 10. Таблица `archive_publish_job` - -Хранит crash-safe состояние одной архивной публикации. - -```sql -CREATE TABLE 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 -); -``` - -Рекомендуемые статусы: - -```text -SNAPSHOT_CREATED -FILE_BUILT -ARWEAVE_UPLOADED -ARWEAVE_CONFIRMED -SOLANA_SUBMITTED -SOLANA_FINALIZED -CURSORS_COMMITTED -FAILED -``` - -Завершённые rows этой таблицы одновременно дают локальную историю больших блоков: - -```text -big_block_number -archive_hash -arweave_tx_id -``` - -По ним строится FULL-список предыдущих больших блоков следующей публикации. - ---- - -# 11. Таблица `archive_publish_job_chain` - -Хранит frozen range каждой blockchain в текущем job и координаты создаваемого chunk. - -```sql -CREATE TABLE archive_publish_job_chain ( - job_id BIGINT NOT NULL, - 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) -); -``` - ---- - -# 12. Заморозка дельты - -Пример: - -```text -локально: -alice-001 = source block 180 -bob-001 = source block 94 - -опубликовано: -alice-001 = 160 -bob-001 = 90 -``` - -Новый job фиксирует: - -```text -alice-001: 161..180 -bob-001: 91..94 -``` - -Если во время загрузки появятся: - -```text -alice-001 181..185 -bob-001 95..97 -``` - -они попадут только в следующий job. - ---- - -# 13. Проверка курсора перед публикацией - -Перед созданием frozen range сервер проверяет: - -```text -local hash(last_archived_source_block_number) -== -archive_chain_cursor.last_archived_source_block_hash -``` - -При несовпадении публикация этой chain останавливается до resync/fork handling. - ---- - -# 14. Ограничение размера большого блока - -Offsets и sizes внутри `SHINE-ARCHIVE v1.0` используют `u32`. - -Поэтому один большой archive-файл MUST быть меньше `4 GiB`. - -Рекомендуемый запас: - -```properties -archive.maxFileBytes=4000000000 -``` - -Если собранный frozen job превышает этот лимит, v1.0 останавливает публикацию с явной ошибкой `ArchiveTooLargeException` и не двигает курсоры. Практически лимит очень велик; для такого сервера следует уменьшить объём данных между суточными закрытиями или реализовать деление snapshot на несколько big blocks. Автоматическое деление одного snapshot на несколько big blocks оставлено как совместимое будущее расширение. - ---- - -# 15. Magic и версия - -Файл начинается ASCII: - -```text -SHINE-ARCHIVE -``` - -Размер: 13 байт. - -Далее: - -```text -u8 version_major -u8 version_minor -``` - -Для v1.0: - -```text -1 -0 -``` - ---- - -# 16. Общий layout большого блока - -```text -+------------------------------------------+ -| FIXED HEADER | -+------------------------------------------+ -| creator_login | -+------------------------------------------+ -| PREVIOUS BIG BLOCK REFERENCES | -| ref[0] | -| ref[1] | -| ... | -+------------------------------------------+ -| UserBlockchainChunk #1 | -| UserBlockchainChunk #2 | -| ... | -+------------------------------------------+ -| FOOTER: closer_login | -| block_hash | -| signature | -+------------------------------------------+ -``` - ---- - -# 17. Fixed Header v1.0 - -Все integers — big-endian. - -| Поле | Тип | Размер | -|---|---:|---:| -| magic | bytes[13] | 13 | -| version_major | u8 | 1 | -| version_minor | u8 | 1 | -| header_size | u32 | 4 | -| big_block_number | u32 | 4 | -| created_at_ms | u64 | 8 | -| creator_login_length | u8 | 1 | -| references_mode | u8 | 1 | -| references_count | u32 | 4 | -| reference_entry_size | u16 | 2 | -| parent_reference_index | u32 | 4 | -| blockchain_chunks_count | u32 | 4 | -| total_user_records_count | u32 | 4 | - -После fixed header немедленно идут `creator_login` bytes. - -`header_size` — byte offset первого `PreviousBigBlockReference`. - ---- - -# 18. `creator_login` - -SHiNE login пользователя/сервера, который сформировал большой блок. - -```text -u8 creator_login_length -bytes[N] creator_login UTF-8 -``` - -Этот login находится в начале файла, поэтому его можно узнать без скачивания payload. - ---- - -# 19. `big_block_number` - -```text -u32 -``` - -Рекомендуемая нумерация опубликованных больших блоков: - -```text -first = 1 -next = 2 -next = 3 -... -``` - -Первый реально публикуемый большой блок имеет номер `1`, поэтому его локальное имя содержит `00001`. Неудачная незавершённая попытка не становится частью опубликованной archive-цепочки. - ---- - -# 20. `created_at_ms` - -```text -u64 -``` - -Unix Epoch UTC milliseconds. - -Это время заморозки содержимого big block. - ---- - -# 21. Режим ссылок на предыдущие большие блоки - -```text -references_mode u8 -``` - -Значения: - -```text -0 = FULL -1 = PARTIAL, зарезервировано на будущее -``` - -Writer v1.0 MUST использовать `FULL`. - ---- - -# 22. FULL previous-block table - -Новый big block содержит ссылки на ВСЕ предыдущие большие блоки своей ветки. - -Пример: - -```text -BigBlock #365 - -References: -#1 -#2 -#3 -... -#364 -``` - -При одном big block в день через год таблица занимает примерно 24.8 KiB. - ---- - -# 23. `PreviousBigBlockReference` - -Фиксированный размер: 68 байт. - -| Поле | Тип | Размер | -|---|---:|---:| -| big_block_number | u32 | 4 | -| big_block_hash | bytes[32] | 32 | -| arweave_tx_id | bytes[32] | 32 | - -Семантика: - -```text -big_block_number = логический номер -big_block_hash = криптографическая идентичность -arweave_tx_id = где скачать блок -``` - ---- - -# 24. `references_count` - -Количество entries в таблице ссылок. - -```text -u32 -``` - ---- - -# 25. `reference_entry_size` - -```text -u16 -``` - -Для v1.0: - -```text -68 -``` - -Поле оставлено для будущего расширения entry. - ---- - -# 26. `parent_reference_index` - -```text -u32 -``` - -Индекс непосредственного родителя внутри reference table. - -Для первого большого блока (`#1`), у которого нет родителя: - -```text -0xFFFFFFFF -``` - -В простой FULL-линейной ветке обычно это последний entry. - ---- - -# 27. Быстрое скачивание начала блока - -Клиент сначала скачивает fixed header + creator login. - -Из него узнаёт: - -```text -header_size -references_count -reference_entry_size -``` - -Затем скачивает только reference table и уже имеет адреса/hash всех предыдущих больших блоков. - -Payload пользователей можно пока не скачивать. - ---- - -# 28. `blockchain_chunks_count` - -```text -u32 -``` - -Количество `UserBlockchainChunk` в текущем big block. - -Одна `blockchain_name` встречается максимум один раз. - ---- - -# 29. `total_user_records_count` - -```text -u32 -``` - -Общее количество исходных SHiNE-записей во всех chunks. - ---- - -# 30. Порядок chunks и records - -Для детерминированной сериализации chunks SHOULD сортироваться по `blockchain_name ASC`. - -Внутри chunk исходные записи MUST идти по возрастанию номера исходного SHiNE-блока. - ---- - -# 31. Формат `UserBlockchainChunk` - -```text -u32 chunk_size - -u8 blockchain_name_length -bytes[N] blockchain_name UTF-8 - -u32 records_count - -repeat records_count: - u32 record_size - bytes[M] raw_record_bytes - -u32 previous_big_block_ref -u32 previous_chunk_offset -u32 previous_chunk_size -``` - ---- - -# 32. `chunk_size` - -```text -u32 -``` - -Полный размер chunk от первого байта `chunk_size` до последнего байта `previous_chunk_size` включительно. - -Это позволяет: - -- быстро пропустить chunk; -- скачать его Range-запросом; -- сохранить его точные координаты для следующего backlink. - ---- - -# 33. `blockchain_name` внутри chunk - -Хранится только `blockchain_name`. - -Отдельный `login` не нужен. - ---- - -# 34. `records_count` - -```text -u32 -``` - -Если за период у `alice-001` появилось 7 записей: - -```text -records_count = 7 -``` - -Все 7 находятся в одном chunk. - ---- - -# 35. Raw records - -Каждая исходная запись сериализуется: - -```text -u32 record_size -bytes[record_size] raw_record_bytes -``` - -`raw_record_bytes` — оригинальные данные SHiNE из БД. - -Сервер не меняет их содержимое. - ---- - -# 36. Один backlink на весь chunk - -После всех raw records находится ровно один `PreviousBlockchainChunkRef`. - -Он относится ко всей пачке записей blockchain. - -Он не повторяется у отдельных записей. - ---- - -# 37. `PreviousBlockchainChunkRef` - -| Поле | Тип | Размер | -|---|---:|---:| -| previous_big_block_ref | u32 | 4 | -| previous_chunk_offset | u32 | 4 | -| previous_chunk_size | u32 | 4 | - -Итого 12 байт на весь chunk. - ---- - -# 38. `previous_big_block_ref` - -Индекс в `PreviousBigBlockReferences[]` текущего big block. - -Он указывает на тот предыдущий big block, где находится предыдущий chunk этой же `blockchain_name`. - -Это именно reference index, а не номер блока. - ---- - -# 39. `previous_chunk_offset` - -```text -u32 -``` - -Byte offset от начала предыдущего `SHINE-ARCHIVE` до первого байта предыдущего chunk этой blockchain. - -Первый байт chunk — поле `chunk_size`. - ---- - -# 40. `previous_chunk_size` - -```text -u32 -``` - -Полный размер предыдущего chunk. - -Поэтому клиент может Range-запросом забрать сразу всю предыдущую пачку записей пользователя. - ---- - -# 41. Первая публикация blockchain - -Если у данной `blockchain_name` нет предыдущего архивного chunk: - -```text -previous_big_block_ref = 0xFFFFFFFF -previous_chunk_offset = 0 -previous_chunk_size = 0 -``` - ---- - -# 42. Пример backlink-цепочки - -В `BigBlock #100` есть `alice-001` chunk из 4 записей. - -Его backlink: - -```text -previous_big_block_ref = 94 -previous_chunk_offset = 8123 -previous_chunk_size = 1770 -``` - -`ref[94]` содержит: - -```text -big_block_number = 95 -big_block_hash = ... -arweave_tx_id = ... -``` - -Клиент: - -1. получает TX big block #95; -2. проверяет hash; -3. делает Range на `8123 .. 8123+1770`; -4. получает весь предыдущий `alice-001` chunk; -5. читает его записи; -6. берёт следующий backlink. - ---- - -# 43. Почему TX ID не хранится в каждом chunk - -TX ID и hash предыдущих больших блоков уже один раз записаны в общей reference table. - -Поэтому в chunk достаточно компактного `u32 previous_big_block_ref`. - ---- - -# 44. Footer большого блока - -После последнего chunk: - -```text -u8 closer_login_length -bytes[N] closer_login UTF-8 - -bytes[32] block_hash -bytes[64] closer_signature -``` - ---- - -# 45. `closer_login` - -Login пользователя/сервера, который сформировал и закрыл big block. - -В v1.0: - -```text -closer_login MUST == creator_login -``` - -Login повторяется намеренно: - -- header даёт автора сразу; -- footer явно фиксирует того, кто закрыл блок; -- hash покрывает footer login; -- verifier проверяет совпадение. - ---- - -# 46. `block_hash` - -```text -bytes[32] -SHA-256 -``` - -Хэшируется всё от первого байта magic до последнего байта `closer_login` включительно. - -Не входят: - -```text -block_hash -closer_signature -``` - -Псевдокод: - -```text -hash_input = - fixed_header - + creator_login - + references - + all UserBlockchainChunks - + closer_login_length - + closer_login - -block_hash = SHA256(hash_input) -``` - ---- - -# 47. `closer_signature` - -```text -bytes[64] -Ed25519 -``` - -Подписывается: - -```text -ASCII("SHINE-ARCHIVE-V1") || block_hash -``` - -Для v1.0 используется root private key аккаунта `creator_login`. - ---- - -# 48. Проверка подписи - -Verifier: - -1. читает `creator_login`; -2. проверяет `creator_login == closer_login`; -3. получает root public key аккаунта из User PDA / исторического состояния Solana; -4. пересчитывает `block_hash`; -5. проверяет Ed25519 signature. - -Старые архивы должны оставаться проверяемыми после ротации root key, поэтому исторические PDA-состояния нужно сохранять/уметь получать. - ---- - -# 49. Собственный Arweave TX ID текущего блока - -Текущий файл не может заранее знать свой будущий TX ID. - -Поэтому собственный TX ID: - -- не входит в сам текущий файл; -- сохраняется после upload; -- записывается в Solana PDA; -- появляется как previous-block reference уже в следующем big block. - ---- - -# 50. User PDA block type `100` - -Archive head хранится прямо в существующем User PDA publisher-а. - -Формат: - -```text -u8 block_type = 100 -u8 block_version = 0 -bytes[32] archive_tx_id -bytes[32] archive_hash -``` - -Итого 66 байт. - -`archive_hash` = `block_hash` последнего успешно опубликованного big block. - ---- - -# 51. Обновление Solana - -Отдельная новая Solana instruction не требуется. - -Используется существующий `update_user_pda`. - -Нужно расширить существующие serializer/parser/codec так, чтобы block type 100: - -- читался; -- записывался; -- сохранялся обычными обновлениями; -- мог быть изменён архивным сервером обычным update User PDA. - ---- - -# 52. Полный порядок публикации - -```text -1. Существующая server-to-server sync независимо обновляет локальные blockchains. - -2. Scheduler запускает archive job. - -3. Проверяется отсутствие другого активного archive job. - -4. Читаются archive_chain_cursor. - -5. Читаются текущие локальные blockchain heads. - -6. Для каждой blockchain вычисляется frozen delta. - -7. Создаётся archive_publish_job. - -8. Создаются archive_publish_job_chain rows. - -9. Загружается история всех успешно finalized previous big blocks. - -10. Формируется fixed header. - -11. Записывается creator_login. - -12. Записывается FULL PreviousBigBlockReferences. - -13. Для каждой blockchain_name: - - читается frozen range; - - создаётся ровно один UserBlockchainChunk; - - внутрь кладутся все новые records; - - добавляется один PreviousBlockchainChunkRef; - - сохраняются new_chunk_offset/new_chunk_size. - -14. Записывается closer_login. - -15. Вычисляется SHA-256 block_hash. - -16. Создаётся Ed25519 closer_signature. - -17. Файл закрывается. - -18. Файл загружается в Arweave. - -19. TX ID сохраняется в archive_publish_job. - -20. Сервер ждёт требуемое число Arweave confirmations. - -21. Обычным update_user_pda обновляется block type 100: - archive_tx_id - archive_hash - -22. Сервер ждёт Solana finalized. - -23. В одной DB transaction обновляются archive_chain_cursor. - -24. Job получает CURSORS_COMMITTED. -``` - ---- - -# 53. Cursor commit - -Для каждой blockchain из job: - -```text -last_archived_source_block_number = to_source_block_number -last_archived_source_block_hash = last_source_block_hash - -last_archive_big_block_number = current big_block_number -last_archive_big_block_hash = current block_hash - -last_chunk_offset = new_chunk_offset -last_chunk_size = new_chunk_size -``` - -Следующая публикация сразу знает, куда должен вести backlink. - ---- - -# 54. Arweave service - -Старый `TestFreeAvatarArweaveService` удаляется из активного протокола; archive publisher использует отдельный `ArweaveArchiveService`. - -Его следует переделать/переименовать, например в: - -```text -ArweaveArchiveService -``` - -Он должен: - -- использовать Arweave JWK; -- подписывать и оплачивать upload; -- поддерживать большие/chunked uploads; -- возвращать TX ID; -- ждать подтверждения; -- опрашивать confirmations. - -Рекомендуемые настройки: - -```properties -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 -``` - ---- - -# 55. Solana finalization - -После обычного `update_user_pda` сервер должен дождаться: - -```text -finalized -``` - -Только после этого разрешается сдвинуть локальные курсоры. - ---- - -# 56. Crash recovery - -## После `SNAPSHOT_CREATED` - -Возобновить тот же frozen job. - -## После `FILE_BUILT` - -Использовать тот же локальный файл. - -## После `ARWEAVE_UPLOADED` - -Не загружать второй раз. Продолжить по сохранённому TX ID. - -## После `ARWEAVE_CONFIRMED` - -Продолжить Solana update. - -## После `SOLANA_SUBMITTED` - -Сначала проверить transaction / текущий PDA. - -## После `SOLANA_FINALIZED`, но до cursor commit - -Проверить: - -```text -PDA.archive_tx_id == expected_tx -PDA.archive_hash == expected_hash -``` - -После этого безопасно выполнить cursor commit. - ---- - -# 57. Нет новых данных - -Если новых записей нет ни в одной blockchain: - -```text -не создавать пустой SHINE-ARCHIVE -не загружать Arweave -не обновлять Solana -``` - ---- - -# 58. FULL references как стартовая стратегия - -v1.0 сознательно хранит ВСЕ previous big blocks в каждом новом big block. - -Плюсы: - -- актуальный блок сразу даёт карту всей предыдущей истории; -- не нужно идти parent-by-parent; -- backlink может использовать компактный ref index; -- reader очень простой. - -При одном big block в сутки: - -```text -1 год: ~24.8 KiB -5 лет: ~121 KiB -20 лет: ~485 KiB -``` - ---- - -# 59. PARTIAL references - -`references_mode = 1` зарезервирован на будущее. - -v1.0 writer его не реализует. - -В будущем PARTIAL может означать: - -- только реально используемые previous blocks; -- последние N blocks; -- checkpoint references; -- смешанную стратегию. - ---- - -# 60. Чтение истории одной blockchain - -Имея актуальный chunk `alice-001`, клиент: - -1. читает все records chunk; -2. читает `PreviousBlockchainChunkRef`; -3. если ref = `0xFFFFFFFF`, история закончилась; -4. иначе берёт соответствующий `PreviousBigBlockReference`; -5. получает TX ID + hash previous big block; -6. Range-запросом читает `previous_chunk_offset/previous_chunk_size`; -7. получает сразу всю предыдущую пачку записей `alice-001`; -8. повторяет. - -Так можно читать историю конкретной blockchain, не скачивая чужие chunks. - ---- - -# 61. Дубликаты - -Если исходная SHiNE-запись уже есть у получателя, она пропускается/дедуплицируется стандартной логикой. - ---- - -# 62. Конфликты исходной blockchain - -Архивная ветка сама по себе не является пользовательским конфликтом. - -Конфликт возникает, когда одна `blockchain_name` имеет два несовместимых валидно подписанных продолжения одной предыдущей точки. - -Для v1 безопасно: - -```text -сохранить обе ветки -пометить blockchain CONFLICTED -остановить автоматическое продолжение -``` - -Механизм разрешения fork можно добавить позже. - ---- - -# 63. Авторство большого блока - -Автор указан дважды: - -## Header - -```text -creator_login -``` - -## Footer - -```text -closer_login -block_hash -closer_signature -``` - -Для v1.0: - -```text -creator_login == closer_login -``` - -Это означает: именно этот SHiNE-аккаунт сформировал и криптографически закрыл big block. - ---- - -# 64. Подпись сервера не заменяет подписи пользователей - -`closer_signature` доказывает авторство большого archive block. - -Каждая пользовательская raw record всё равно проверяется существующей криптографией SHiNE. - ---- - -# 65. Независимость от Arweave - -Arweave TX ID — location. - -SHA-256 — identity. - -Поэтому архивы можно перенести на другой storage и всё равно проверять. - ---- - -# 66. Block type 100 в Solana — текущая голова - -User PDA publisher-а хранит только: - -```text -current archive TX ID -current archive SHA-256 -``` - -Детальная история находится внутри big blocks. - ---- - -# 67. Startup preflight - -Перед запуском archive publisher сервер SHOULD проверить: - -```text -derivePublic(root_private) == UserPDA.root_public_key -``` - -и: - -```text -derivePublic(client_private) == UserPDA.client_key -``` - -При несовпадении archive publisher не запускается. - ---- - -# 68. Рекомендуемые настройки - -```properties -archive.publish.enabled=false -archive.publish.time=00:00 -archive.publish.zoneId= -archive.workDir=data/archive - -archive.maxFileBytes=4000000000 - -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 - -archive.solana.rootKeyPath=/opt/shine/secrets/root.key -archive.solana.clientKeyPath=/opt/shine/secrets/client.key -archive.solana.commitment=finalized - -# Отдельного archive.solana.rpcUrl нет. -# Используется solana.users.sync.rpcUrl, а если он пуст — обычный solana.rpcUrl. -``` - ---- - -# 69. Требование к `AppConfig` - -Archive settings должны читаться из: - -- Java system properties; -- environment variables; -- `application.properties`. - -`getInt()` и `getBoolean()` должны использовать общую логику `getParam()`. - ---- - -# 70. Что переиспользуется из текущего сервера - -Существующие механизмы: - -```text -BlockchainStateDAO.listAll() -BlocksDAO.listRangeByNumber(...) -``` - -дают необходимые heads и raw block ranges. - -Используются существующие: - -```text -block_bytes -block_hash -block_signature -blockchain_name -block_number -``` - -Server-to-server sync в v1.0 не меняется. - ---- - -# 71. Блокировки - -Можно использовать существующий blockchain lock только на коротком этапе: - -```text -проверить cursor hash -прочитать frozen range -проверить конечный hash -сохранить/сериализовать frozen data -``` - -Lock не удерживается во время Arweave/Solana ожиданий. - ---- - -# 72. Реализационный checklist - -## Database - -- [x] `archive_chain_cursor` -- [x] `archive_publish_job` -- [x] `archive_publish_job_chain` -- [x] schema migration -- [x] crash recovery - -## Archive writer - -- [x] magic `SHINE-ARCHIVE` -- [x] major/minor version -- [x] fixed header -- [x] creator login -- [x] FULL previous big block table -- [x] one chunk per `blockchain_name` -- [x] many raw records inside one chunk -- [x] one backlink per chunk -- [x] closer login -- [x] SHA-256 -- [x] Ed25519 signature -- [x] max file size < 4 GiB -- [x] local `.tmp.SHiNE-archive -> ..SHiNE-archive` lifecycle in `data/archive` - -## Arweave - -- [x] rename/refactor `TestFreeAvatarArweaveService` -- [x] remove avatar-specific logic -- [x] large/chunked upload -- [x] confirmation polling - -## Solana - -- [x] add PDA block type `100` -- [x] update Rust codec -- [x] update Java codec -- [x] update JS codec/UI writer -- [x] use ordinary `update_user_pda` -- [x] server-side transaction writer -- [x] root signature -- [x] client fee payer -- [x] wait for `finalized` - -## Scheduler - -- [x] `archive.publish.enabled` -- [x] `archive.publish.time` -- [x] `archive.publish.zoneId` -- [x] no concurrent jobs -- [ ] future: автоматическое split > max size (v1.0 сейчас безопасно останавливается без cursor commit) -- [x] skip when no new blocks - ---- - -# 73. Итоговая бинарная схема v1.0 - -```text -SHINE-ARCHIVE -version_major u8 -version_minor u8 - -header_size u32 -big_block_number u32 -created_at_ms u64 - -creator_login_length u8 - -references_mode u8 -references_count u32 -reference_entry_size u16 -parent_reference_index u32 - -blockchain_chunks_count u32 -total_user_records_count u32 - -creator_login bytes - - -PreviousBigBlockReference[references_count]: - - big_block_number u32 - big_block_hash [32] - arweave_tx_id [32] - - -UserBlockchainChunk[blockchain_chunks_count]: - - chunk_size u32 - - blockchain_name_length u8 - blockchain_name bytes - - records_count u32 - - repeat records_count: - record_size u32 - raw_record_bytes - - previous_big_block_ref u32 - previous_chunk_offset u32 - previous_chunk_size u32 - - -FOOTER: - - closer_login_length u8 - closer_login bytes - - block_hash [32] - closer_signature [64] -``` - ---- - -# 74. Основная архитектура одним рисунком - -```text -SOLANA USER PDA - | - | block type 100 - | TX + HASH - v -BigBlock #100 - | - +-- Header - | creator = server-A - | - +-- References - | ref[0] -> BigBlock #1 / hash / TX - | ref[1] -> BigBlock #2 / hash / TX - | ... - | ref[98] -> BigBlock #99 / hash / TX - | - +-- alice-001 chunk - | records x4 - | | - | +--> BigBlock #95 / AliceChunk - | - +-- bob-001 chunk - | records x2 - | | - | +--> BigBlock #98 / BobChunk - | - +-- kate-002 chunk - | records x10 - | | - | +--> BigBlock #74 / KateChunk - | - +-- Footer - closer = server-A - SHA-256 - Ed25519 signature -``` - ---- - -# 75. Итоговые решения v1.0 - -- один большой блок = одна архивная публикация; -- в начале лежит полный список всех предыдущих больших блоков; -- каждый previous block reference содержит `number + SHA-256 + Arweave TX ID`; -- одна `blockchain_name` встречается в большом блоке максимум одним chunk; -- все новые записи этой blockchain объединяются в этот chunk; -- chunk имеет одну ссылку на предыдущий chunk этой же blockchain; -- backlink состоит из `previous big block ref + chunk offset + chunk size`; -- сервер сам создаёт backlink; -- пользовательские raw records не меняются; -- login сервера есть в header и footer; -- большой блок закрывается SHA-256 + Ed25519 signature сервера; -- Solana User PDA block `100` хранит текущий TX ID + archive hash; -- PDA обновляется обычным `update_user_pda`; -- Arweave используется как долговременное хранилище; -- публикация считается завершённой только после Arweave confirmation + Solana finalized + cursor commit. diff --git a/docs/Solana/user_pda/README.md b/docs/Solana/user_pda/README.md index ac2e64c0..aa1b07b7 100644 --- a/docs/Solana/user_pda/README.md +++ b/docs/Solana/user_pda/README.md @@ -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 без изменений**; diff --git a/docs/Solana_Architecture/details/shine_users.md b/docs/Solana_Architecture/details/shine_users.md index aa7f6f4f..cc55f570 100644 --- a/docs/Solana_Architecture/details/shine_users.md +++ b/docs/Solana_Architecture/details/shine_users.md @@ -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 обратно совместим: diff --git a/docs/libs/shine-server-blockchain/Doc.md b/docs/libs/shine-server-blockchain/Doc.md index 151e05c4..d0d174a7 100644 --- a/docs/libs/shine-server-blockchain/Doc.md +++ b/docs/libs/shine-server-blockchain/Doc.md @@ -1,4 +1,4 @@ -shine-server-blockchain — это библиотека, которая задаёт формат блока, правила парсинга/валидации тела, крипто-проверку (hash+Ed25519) и безопасную работу с файлами блокчейна (data/.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) Объяснение структуры работы diff --git a/javac.20260920_155256.args b/javac.20260920_155256.args new file mode 100644 index 00000000..5d0cf8ff --- /dev/null +++ b/javac.20260920_155256.args @@ -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 diff --git a/shine-UI/Blockchain-Viewer.html b/shine-UI/Blockchain-Viewer.html deleted file mode 100644 index 7a54eda5..00000000 --- a/shine-UI/Blockchain-Viewer.html +++ /dev/null @@ -1,3416 +0,0 @@ - - - - - - SHiNE Blockchain Viewer - - - - - -
-
- - - - - -
- -
- - -
- -
Готово к загрузке.
- -
-
-
После загрузки PDA viewer покажет список каналов.
-
- - -
-
- - diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index dafd5c52..bf1eb4e4 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -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, diff --git a/shine-UI/js/pages/blockchain-archive-view.js b/shine-UI/js/pages/blockchain-archive-view.js deleted file mode 100644 index 14c73909..00000000 --- a/shine-UI/js/pages/blockchain-archive-view.js +++ /dev/null @@ -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 = ` -
- Последний архивный блок -
Загружаю...
-
- - - - -
- - -
-
- Технические данные -

-    
- `; - 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; -} diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index 44614949..20ad280e 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -71,12 +71,6 @@ export function render({navigate, chrome}) { Solana, SHiNE и Arweave для публичных данных -