diff --git a/SHiNE-server/shine-server-archive/build.gradle b/SHiNE-server/shine-server-archive/build.gradle new file mode 100644 index 00000000..dca78bdc --- /dev/null +++ b/SHiNE-server/shine-server-archive/build.gradle @@ -0,0 +1,31 @@ +plugins { + id 'java' +} + +group = 'shine' +version = '1.0.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { mavenCentral() } + +dependencies { + implementation project(':shine-server-config') + implementation project(':shine-server-db') + implementation project(':shine-server-crypto') + implementation project(':shine-server-blockchain') + implementation project(':shine-server-net-protocol') + implementation project(':shine-server-solana-users-sync') + + implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' + implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' + implementation 'org.slf4j:slf4j-api:2.0.16' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' +} + +test { useJUnitPlatform() } 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 new file mode 100644 index 00000000..55eff901 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java @@ -0,0 +1,56 @@ +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/ArchiveKeyLoader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java new file mode 100644 index 00000000..0e6d4b37 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java @@ -0,0 +1,75 @@ +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 new file mode 100644 index 00000000..d4ed7e00 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java @@ -0,0 +1,98 @@ +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 new file mode 100644 index 00000000..10afac77 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java @@ -0,0 +1,90 @@ +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 new file mode 100644 index 00000000..a523f849 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java @@ -0,0 +1,302 @@ +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.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 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(); + } + + /** После рестарта продолжает только уже существующий незавершённый 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()); + } + + 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 new file mode 100644 index 00000000..2711d4c7 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java @@ -0,0 +1,259 @@ +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/ArweaveMerkle.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java new file mode 100644 index 00000000..a33c81ef --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java @@ -0,0 +1,157 @@ +package server.archive; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; + +/** + * Arweave v2 data Merkle tree/proofs. Алгоритм повторяет arweave-js merkle.ts. + */ +final class ArweaveMerkle { + static final int MAX_CHUNK_SIZE = 256 * 1024; + static final int MIN_CHUNK_SIZE = 32 * 1024; + + record Chunk(byte[] dataHash, long minByteRange, long maxByteRange) { + long size() { return maxByteRange - minByteRange; } + } + record Proof(long offset, byte[] proof) {} + record Prepared(byte[] dataRoot, List chunks, List proofs) {} + + private sealed interface Node permits Leaf, Branch { + byte[] id(); + long maxByteRange(); + } + private record Leaf(byte[] id, byte[] dataHash, long minByteRange, long maxByteRange) implements Node {} + private record Branch(byte[] id, long byteRange, long maxByteRange, Node left, Node right) implements Node {} + + private ArweaveMerkle() {} + + static Prepared prepare(Path file) throws IOException { + long fileSize = Files.size(file); + List chunks = chunkFile(file, fileSize); + 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())); + } + if (leaves.isEmpty()) throw new IOException("Arweave Merkle: нет leaves"); + Node root = buildLayers(leaves); + List proofs = new ArrayList<>(); + resolveProofs(root, new byte[0], proofs); + + // arweave-js строит root вместе с нулевым последним chunk при точном кратном 256KiB, + // но сам нулевой chunk/proof не загружает. + 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 chunkFile(Path file, long fileSize) throws IOException { + List out = new ArrayList<>(); + long remaining = fileSize; + long cursor = 0; + try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) { + 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[] data = readExact(in, (int)chunkSize); + out.add(new Chunk(sha256(data), cursor, cursor + chunkSize)); + cursor += chunkSize; + remaining -= chunkSize; + } + byte[] rest = readExact(in, (int)remaining); + out.add(new Chunk(sha256(rest), cursor, cursor + remaining)); + } + return out; + } + + private static Node buildLayers(List nodes) { + if (nodes.size() < 2) return nodes.get(0); + List next = new ArrayList<>((nodes.size() + 1) / 2); + for (int i = 0; i < nodes.size(); i += 2) { + Node left = nodes.get(i); + Node right = i + 1 < nodes.size() ? nodes.get(i + 1) : null; + if (right == null) { + next.add(left); + continue; + } + long byteRange = left.maxByteRange(); + byte[] id = sha256(concat( + sha256(left.id()), + sha256(right.id()), + sha256(note(byteRange)) + )); + next.add(new Branch(id, byteRange, right.maxByteRange(), left, right)); + } + return buildLayers(next); + } + + private static void resolveProofs(Node node, byte[] prefix, List out) { + if (node instanceof Leaf leaf) { + out.add(new Proof(leaf.maxByteRange() - 1, + concat(prefix, leaf.dataHash(), note(leaf.maxByteRange())))); + return; + } + Branch b = (Branch) node; + byte[] partial = concat(prefix, b.left().id(), b.right().id(), note(b.byteRange())); + resolveProofs(b.left(), partial, out); + resolveProofs(b.right(), partial, out); + } + + static byte[] readChunk(Path file, Chunk c) throws IOException { + long len = c.size(); + if (len < 0 || len > Integer.MAX_VALUE) throw new IOException("Некорректный Arweave chunk size: " + len); + try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) { + raf.seek(c.minByteRange()); + byte[] data = new byte[(int)len]; + raf.readFully(data); + return data; + } + } + + private static byte[] readExact(InputStream in, int len) throws IOException { + byte[] out = new byte[len]; + int pos = 0; + while (pos < len) { + int n = in.read(out, pos, len - pos); + if (n < 0) throw new EOFException("Неожиданный EOF"); + pos += n; + } + return out; + } + + private static byte[] note(long value) { + if (value < 0) throw new IllegalArgumentException("negative note"); + byte[] out = new byte[32]; + long v = value; + for (int i = 31; i >= 0 && v != 0; i--) { + out[i] = (byte)(v & 0xff); + v >>>= 8; + } + return out; + } + + private static byte[] sha256(byte[] data) { + try { return MessageDigest.getInstance("SHA-256").digest(data); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + + private static byte[] concat(byte[]... arrays) { + int len = 0; + for (byte[] a : arrays) len = Math.addExact(len, a.length); + byte[] out = new byte[len]; + int p=0; + for (byte[] a : arrays) { System.arraycopy(a,0,out,p,a.length); p+=a.length; } + return out; + } +} 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 new file mode 100644 index 00000000..e1e99d4e --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java @@ -0,0 +1,310 @@ +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 new file mode 100644 index 00000000..33ea9c65 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java @@ -0,0 +1,373 @@ +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/ArchiveFileNamesTest.java b/SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java new file mode 100644 index 00000000..5f394f39 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java @@ -0,0 +1,34 @@ +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 new file mode 100644 index 00000000..7fdcd2de --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java @@ -0,0 +1,83 @@ +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-config/src/main/java/utils/config/AppConfig.java b/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java index 027779b0..14a71683 100644 --- a/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java +++ b/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java @@ -66,19 +66,20 @@ public final class AppConfig { /** Вернёт строку или пустую строку, если параметр не найден. */ public String getStringOrEmpty(String name) { - String value = properties.getProperty(name); + String value = getParam(name); return value == null ? "" : value.trim(); } - /** Можно добавить методы для удобства */ + /** Целочисленный параметр с тем же приоритетом: system property -> env -> properties. */ public int getInt(String name, int defaultValue) { - String v = properties.getProperty(name); - return v == null ? defaultValue : Integer.parseInt(v); + String v = getParam(name); + return v == null || v.isBlank() ? defaultValue : Integer.parseInt(v.trim()); } + /** Boolean-параметр с тем же приоритетом: system property -> env -> properties. */ public boolean getBoolean(String name, boolean defaultValue) { - String v = properties.getProperty(name); - return v == null ? defaultValue : Boolean.parseBoolean(v); + String v = getParam(name); + return v == null || v.isBlank() ? defaultValue : Boolean.parseBoolean(v.trim()); } private static String toEnvName(String name) { 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 021e9c8b..52a19ca8 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 @@ -38,6 +38,7 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_19 = 19; public static final int SCHEMA_VERSION_20 = 20; public static final int SCHEMA_VERSION_21 = 21; + public static final int SCHEMA_VERSION_22 = 22; 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"; @@ -59,6 +60,7 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql"; public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql"; public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql"; + public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql"; private DatabaseInitializer() {} @@ -212,6 +214,10 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE); currentVersion = SCHEMA_VERSION_21; } + if (currentVersion < SCHEMA_VERSION_22) { + runSqlScript(conn, POSTGRES_MIGRATION_V22_RESOURCE); + currentVersion = SCHEMA_VERSION_22; + } } } 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 new file mode 100644 index 00000000..027d6b03 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java @@ -0,0 +1,8 @@ +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/ArchiveChainCursor.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java new file mode 100644 index 00000000..db9cd0ea --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java @@ -0,0 +1,15 @@ +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 new file mode 100644 index 00000000..9fff0a20 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java @@ -0,0 +1,16 @@ +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 new file mode 100644 index 00000000..36a665a8 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java @@ -0,0 +1,17 @@ +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/dao/ArchivePublicationDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java new file mode 100644 index 00000000..0d241896 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java @@ -0,0 +1,472 @@ +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/resources/postgres/migration_v22.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql new file mode 100644 index 00000000..ae030254 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql @@ -0,0 +1,56 @@ +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 + ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_history + ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_history + ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; + +UPDATE db_schema_version SET schema_version = 22, 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 52081438..337a4d71 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 @@ -2028,8 +2028,63 @@ 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 + ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_history + ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_history + ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; + INSERT INTO db_schema_version(id,schema_version,updated_at_ms) -VALUES(1,21,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +VALUES(1,22,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 5dbf6e86..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 @@ -46,11 +46,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request; // --- NEW: SearchUsers --- import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler; @@ -165,8 +161,6 @@ public final class JsonHandlerRegistry { private static final Map HANDLERS = Map.ofEntries( Map.entry("GetUser", new Net_GetUser_Handler()), Map.entry("SearchUsers", new Net_SearchUsers_Handler()), - Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()), - Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()), // --- auth --- Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()), @@ -259,8 +253,6 @@ public final class JsonHandlerRegistry { private static final Map> REQUEST_TYPES = Map.ofEntries( Map.entry("GetUser", Net_GetUser_Request.class), Map.entry("SearchUsers", Net_SearchUsers_Request.class), - Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class), - Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class), // --- auth --- Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class), diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java index 38597f90..0b17c1e6 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java @@ -59,6 +59,9 @@ public final class ShineUsersCodec { private static final int BLOCK_TYPE_TRUSTED_STATE = 70; + private static final int BLOCK_TYPE_ARCHIVE_HEAD = + 100; + private static final int BLOCK_VERSION_0 = 0; @@ -306,6 +309,12 @@ public final class ShineUsersCodec { int trustedCount = 0; + String archiveHeadTxId = + ""; + + String archiveHeadHash = + ""; + for (int i = 0; i < blocksCount; i++) { int blockType = @@ -455,6 +464,13 @@ public final class ShineUsersCodec { trustedCount = reader.readU8(); + case BLOCK_TYPE_ARCHIVE_HEAD -> { + archiveHeadTxId = + Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32)); + archiveHeadHash = + toHex(reader.readFixed(32)); + } + default -> throw new IllegalArgumentException( "Unsupported block type: " + blockType @@ -484,6 +500,8 @@ public final class ShineUsersCodec { lastBlockHash, lastBlockSignature, arweaveTxId, + archiveHeadTxId, + archiveHeadHash, isServer, addressFormatType, addressFormatVersion, @@ -520,7 +538,9 @@ public final class ShineUsersCodec { mutation.createdAtMs(), mutation.createdAtMs(), toHex(ZERO_HASH), - paidLimitBytes + paidLimitBytes, + mutation.fields().archiveHeadSupplied() ? mutation.fields().archiveHeadTxId() : "", + mutation.fields().archiveHeadSupplied() ? mutation.fields().archiveHeadHash() : "" ); } @@ -535,6 +555,13 @@ public final class ShineUsersCodec { previous.paidLimitBytes() + mutation.additionalLimit(); + String archiveTx = mutation.fields().archiveHeadSupplied() + ? mutation.fields().archiveHeadTxId() + : previous.archiveHeadTxId(); + String archiveHash = mutation.fields().archiveHeadSupplied() + ? mutation.fields().archiveHeadHash() + : previous.archiveHeadHash(); + return buildSnapshot( mutation, mutation.version(), @@ -543,7 +570,9 @@ public final class ShineUsersCodec { mutation.createdAtMs(), mutation.updatedAtMs(), toHex(mutation.prevHash()), - paidLimitBytes + paidLimitBytes, + archiveTx, + archiveHash ); } @@ -617,10 +646,13 @@ public final class ShineUsersCodec { pushFixed(out, prevHash); pushStringU8(out, loginBytes); + boolean hasArchiveHead = + snapshot.archiveHeadTxId() != null + && !snapshot.archiveHeadTxId().isBlank(); + int blocksCount = - snapshot.isServer() - ? 8 - : 7; + (snapshot.isServer() ? 8 : 7) + + (hasArchiveHead ? 1 : 0); out.add((byte) blocksCount); @@ -695,6 +727,18 @@ public final class ShineUsersCodec { out.add((byte) BLOCK_VERSION_0); out.add((byte) snapshot.trustedCount()); + if (hasArchiveHead) { + byte[] txId = Base64.getUrlDecoder().decode(snapshot.archiveHeadTxId()); + byte[] archiveHash = fromHex(snapshot.archiveHeadHash()); + if (txId.length != 32 || archiveHash.length != 32) { + throw new IllegalArgumentException("Archive head должен содержать txId/hash по 32 байта"); + } + out.add((byte) BLOCK_TYPE_ARCHIVE_HEAD); + out.add((byte) BLOCK_VERSION_0); + pushFixed(out, txId); + pushFixed(out, archiveHash); + } + int recordLength = out.size() + 64; @@ -871,6 +915,8 @@ public final class ShineUsersCodec { UserFields fields = parseFields(reader); + fields = parseOptionalArchiveExtension(reader, fields); + String recordSignature = Base58Util.encode( reader.readFixed(64) @@ -1008,7 +1054,35 @@ public final class ShineUsersCodec { List.copyOf(accessServers), sessionsMode, List.copyOf(sessions), - trustedCount + trustedCount, + false, + "", + "" + ); + } + + private static UserFields parseOptionalArchiveExtension(Reader reader, UserFields fields) { + if (reader.remaining() == 64) { + return fields; + } + if (reader.remaining() < 65) { + throw new IllegalArgumentException("Некорректная archive extension в update_user_pda"); + } + int present = reader.readU8(); + String tx = ""; + String hash = ""; + if (present == 1) { + tx = Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32)); + hash = toHex(reader.readFixed(32)); + } else if (present != 0) { + throw new IllegalArgumentException("Некорректный archive head marker: " + present); + } + return new UserFields( + fields.clientKey(), fields.blockchainKey(), fields.blockchainName(), fields.usedBytes(), + fields.lastBlockNumber(), fields.lastBlockHash(), fields.lastBlockSignature(), fields.arweaveTxId(), + fields.isServer(), fields.addressFormatType(), fields.addressFormatVersion(), fields.serverAddress(), + fields.syncServers(), fields.accessServers(), fields.sessionsMode(), fields.sessions(), fields.trustedCount(), + true, tx, hash ); } @@ -1020,7 +1094,9 @@ public final class ShineUsersCodec { long createdAtMs, long updatedAtMs, String prevRecordHash, - long paidLimitBytes + long paidLimitBytes, + String archiveHeadTxId, + String archiveHeadHash ) { UserFields fields = @@ -1044,6 +1120,8 @@ public final class ShineUsersCodec { fields.lastBlockHash(), fields.lastBlockSignature(), fields.arweaveTxId(), + archiveHeadTxId == null ? "" : archiveHeadTxId, + archiveHeadHash == null ? "" : archiveHeadHash, fields.isServer(), fields.addressFormatType(), fields.addressFormatVersion(), @@ -1266,6 +1344,10 @@ public final class ShineUsersCodec { ) { cursor += bytes; } + + private int remaining() { + return data.length - cursor; + } } public enum TxKind { @@ -1327,7 +1409,10 @@ public final class ShineUsersCodec { List accessServers, int sessionsMode, List sessions, - int trustedCount + int trustedCount, + boolean archiveHeadSupplied, + String archiveHeadTxId, + String archiveHeadHash ) { } @@ -1389,6 +1474,8 @@ public final class ShineUsersCodec { String lastBlockHash, String lastBlockSignature, String arweaveTxId, + String archiveHeadTxId, + String archiveHeadHash, boolean isServer, int addressFormatType, int addressFormatVersion, @@ -1424,6 +1511,8 @@ public final class ShineUsersCodec { lastBlockHash, lastBlockSignature, arweaveTxId, + archiveHeadTxId, + archiveHeadHash, isServer, addressFormatType, addressFormatVersion, diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java index f279f65d..9f56f004 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java @@ -425,13 +425,13 @@ public final class PostgresStorageRepository "record_number, recovery_key, root_key, client_key, " + "blockchain_name, blockchain_key, paid_limit_bytes, " + "used_bytes, last_block_number, last_block_hash, " + - "last_block_signature, arweave_tx_id, is_server, " + + "last_block_signature, arweave_tx_id, archive_head_tx_id, archive_head_hash, is_server, " + "address_format_type, address_format_version, " + "server_address, sync_servers_json, access_servers_json, " + "sessions_mode, sessions_json, trusted_count, " + "created_at_ms, updated_at_ms, prev_record_hash, " + "record_signature, raw_data_base64, saved_at_ms" + - ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT (pda_address, record_number) DO NOTHING"; try (PreparedStatement statement = @@ -470,13 +470,13 @@ public final class PostgresStorageRepository "recovery_key, root_key, client_key, blockchain_name, " + "blockchain_key, paid_limit_bytes, used_bytes, " + "last_block_number, last_block_hash, last_block_signature, " + - "arweave_tx_id, is_server, address_format_type, " + + "arweave_tx_id, archive_head_tx_id, archive_head_hash, is_server, address_format_type, " + "address_format_version, server_address, sync_servers_json, " + "access_servers_json, sessions_mode, sessions_json, " + "trusted_count, created_at_ms, updated_at_ms, " + "prev_record_hash, record_signature, raw_data_base64, " + "first_seen_at_ms, last_synced_at_ms" + - ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT (pda_address) DO UPDATE SET " + "login = EXCLUDED.login, " + "normalized_login = EXCLUDED.normalized_login, " + @@ -494,6 +494,8 @@ public final class PostgresStorageRepository "last_block_hash = EXCLUDED.last_block_hash, " + "last_block_signature = EXCLUDED.last_block_signature, " + "arweave_tx_id = EXCLUDED.arweave_tx_id, " + + "archive_head_tx_id = EXCLUDED.archive_head_tx_id, " + + "archive_head_hash = EXCLUDED.archive_head_hash, " + "is_server = EXCLUDED.is_server, " + "address_format_type = EXCLUDED.address_format_type, " + "address_format_version = EXCLUDED.address_format_version, " + @@ -536,141 +538,41 @@ public final class PostgresStorageRepository ShineUsersCodec.UserPdaSnapshot snapshot, long nowMs ) throws Exception { - - statement.setString( - 1, - snapshot.lastTxSignature() - ); - statement.setLong( - 2, - snapshot.slot() - ); - statement.setNull( - 3, - Types.BIGINT - ); - statement.setString( - 4, - snapshot.pdaAddress() - ); - statement.setString( - 5, - snapshot.login() - ); - statement.setInt( - 6, - snapshot.recordNumber() - ); - statement.setString( - 7, - snapshot.recoveryKey() - ); - statement.setString( - 8, - snapshot.rootKey() - ); - statement.setString( - 9, - snapshot.clientKey() - ); - statement.setString( - 10, - snapshot.blockchainName() - ); - statement.setString( - 11, - snapshot.blockchainKey() - ); - statement.setLong( - 12, - snapshot.paidLimitBytes() - ); - statement.setLong( - 13, - snapshot.usedBytes() - ); - statement.setInt( - 14, - snapshot.lastBlockNumber() - ); - statement.setString( - 15, - snapshot.lastBlockHash() - ); - statement.setString( - 16, - snapshot.lastBlockSignature() - ); - statement.setString( - 17, - snapshot.arweaveTxId() - ); - statement.setBoolean( - 18, - snapshot.isServer() - ); - statement.setInt( - 19, - snapshot.addressFormatType() - ); - statement.setInt( - 20, - snapshot.addressFormatVersion() - ); - statement.setString( - 21, - snapshot.serverAddress() - ); - statement.setString( - 22, - writeJson( - snapshot.syncServers() - ) - ); - statement.setString( - 23, - writeJson( - snapshot.accessServers() - ) - ); - statement.setInt( - 24, - snapshot.sessionsMode() - ); - statement.setString( - 25, - writeJson( - snapshot.sessions() - ) - ); - statement.setInt( - 26, - snapshot.trustedCount() - ); - statement.setLong( - 27, - snapshot.createdAtMs() - ); - statement.setLong( - 28, - snapshot.updatedAtMs() - ); - statement.setString( - 29, - snapshot.prevRecordHash() - ); - statement.setString( - 30, - snapshot.recordSignature() - ); - statement.setString( - 31, - snapshot.rawDataBase64() - ); - statement.setLong( - 32, - nowMs - ); + int i = 1; + statement.setString(i++, snapshot.lastTxSignature()); + statement.setLong(i++, snapshot.slot()); + statement.setNull(i++, Types.BIGINT); + statement.setString(i++, snapshot.pdaAddress()); + statement.setString(i++, snapshot.login()); + statement.setInt(i++, snapshot.recordNumber()); + statement.setString(i++, snapshot.recoveryKey()); + statement.setString(i++, snapshot.rootKey()); + statement.setString(i++, snapshot.clientKey()); + statement.setString(i++, snapshot.blockchainName()); + statement.setString(i++, snapshot.blockchainKey()); + statement.setLong(i++, snapshot.paidLimitBytes()); + statement.setLong(i++, snapshot.usedBytes()); + statement.setInt(i++, snapshot.lastBlockNumber()); + statement.setString(i++, snapshot.lastBlockHash()); + statement.setString(i++, snapshot.lastBlockSignature()); + statement.setString(i++, snapshot.arweaveTxId()); + statement.setString(i++, snapshot.archiveHeadTxId()); + statement.setString(i++, snapshot.archiveHeadHash()); + statement.setBoolean(i++, snapshot.isServer()); + statement.setInt(i++, snapshot.addressFormatType()); + statement.setInt(i++, snapshot.addressFormatVersion()); + statement.setString(i++, snapshot.serverAddress()); + statement.setString(i++, writeJson(snapshot.syncServers())); + statement.setString(i++, writeJson(snapshot.accessServers())); + statement.setInt(i++, snapshot.sessionsMode()); + statement.setString(i++, writeJson(snapshot.sessions())); + statement.setInt(i++, snapshot.trustedCount()); + statement.setLong(i++, snapshot.createdAtMs()); + statement.setLong(i++, snapshot.updatedAtMs()); + statement.setString(i++, snapshot.prevRecordHash()); + statement.setString(i++, snapshot.recordSignature()); + statement.setString(i++, snapshot.rawDataBase64()); + statement.setLong(i, nowMs); } private void bindCurrentSnapshot( @@ -678,40 +580,42 @@ public final class PostgresStorageRepository ShineUsersCodec.UserPdaSnapshot snapshot, long nowMs ) throws Exception { - - statement.setString(1, snapshot.pdaAddress()); - statement.setString(2, snapshot.login()); - statement.setString(3, normalizeLogin(snapshot.login())); - statement.setInt(4, snapshot.recordNumber()); - statement.setLong(5, snapshot.slot()); - statement.setString(6, snapshot.lastTxSignature()); - statement.setString(7, snapshot.recoveryKey()); - statement.setString(8, snapshot.rootKey()); - statement.setString(9, snapshot.clientKey()); - statement.setString(10, snapshot.blockchainName()); - statement.setString(11, snapshot.blockchainKey()); - statement.setLong(12, snapshot.paidLimitBytes()); - statement.setLong(13, snapshot.usedBytes()); - statement.setInt(14, snapshot.lastBlockNumber()); - statement.setString(15, snapshot.lastBlockHash()); - statement.setString(16, snapshot.lastBlockSignature()); - statement.setString(17, snapshot.arweaveTxId()); - statement.setBoolean(18, snapshot.isServer()); - statement.setInt(19, snapshot.addressFormatType()); - statement.setInt(20, snapshot.addressFormatVersion()); - statement.setString(21, snapshot.serverAddress()); - statement.setString(22, writeJson(snapshot.syncServers())); - statement.setString(23, writeJson(snapshot.accessServers())); - statement.setInt(24, snapshot.sessionsMode()); - statement.setString(25, writeJson(snapshot.sessions())); - statement.setInt(26, snapshot.trustedCount()); - statement.setLong(27, snapshot.createdAtMs()); - statement.setLong(28, snapshot.updatedAtMs()); - statement.setString(29, snapshot.prevRecordHash()); - statement.setString(30, snapshot.recordSignature()); - statement.setString(31, snapshot.rawDataBase64()); - statement.setLong(32, nowMs); - statement.setLong(33, nowMs); + int i=1; + statement.setString(i++, snapshot.pdaAddress()); + statement.setString(i++, snapshot.login()); + statement.setString(i++, normalizeLogin(snapshot.login())); + statement.setInt(i++, snapshot.recordNumber()); + statement.setLong(i++, snapshot.slot()); + statement.setString(i++, snapshot.lastTxSignature()); + statement.setString(i++, snapshot.recoveryKey()); + statement.setString(i++, snapshot.rootKey()); + statement.setString(i++, snapshot.clientKey()); + statement.setString(i++, snapshot.blockchainName()); + statement.setString(i++, snapshot.blockchainKey()); + statement.setLong(i++, snapshot.paidLimitBytes()); + statement.setLong(i++, snapshot.usedBytes()); + statement.setInt(i++, snapshot.lastBlockNumber()); + statement.setString(i++, snapshot.lastBlockHash()); + statement.setString(i++, snapshot.lastBlockSignature()); + statement.setString(i++, snapshot.arweaveTxId()); + statement.setString(i++, snapshot.archiveHeadTxId()); + statement.setString(i++, snapshot.archiveHeadHash()); + statement.setBoolean(i++, snapshot.isServer()); + statement.setInt(i++, snapshot.addressFormatType()); + statement.setInt(i++, snapshot.addressFormatVersion()); + statement.setString(i++, snapshot.serverAddress()); + statement.setString(i++, writeJson(snapshot.syncServers())); + statement.setString(i++, writeJson(snapshot.accessServers())); + statement.setInt(i++, snapshot.sessionsMode()); + statement.setString(i++, writeJson(snapshot.sessions())); + statement.setInt(i++, snapshot.trustedCount()); + statement.setLong(i++, snapshot.createdAtMs()); + statement.setLong(i++, snapshot.updatedAtMs()); + statement.setString(i++, snapshot.prevRecordHash()); + statement.setString(i++, snapshot.recordSignature()); + statement.setString(i++, snapshot.rawDataBase64()); + statement.setLong(i++, nowMs); + statement.setLong(i, nowMs); } private String normalizeLogin(String login) { @@ -763,6 +667,8 @@ public final class PostgresStorageRepository resultSet.getString("last_block_hash"), resultSet.getString("last_block_signature"), resultSet.getString("arweave_tx_id"), + resultSet.getString("archive_head_tx_id"), + resultSet.getString("archive_head_hash"), resultSet.getBoolean("is_server"), resultSet.getInt("address_format_type"), resultSet.getInt("address_format_version"), @@ -933,6 +839,8 @@ public final class PostgresStorageRepository "last_block_hash TEXT NOT NULL, " + "last_block_signature TEXT NOT NULL, " + "arweave_tx_id TEXT NOT NULL, " + + "archive_head_tx_id TEXT NOT NULL DEFAULT '', " + + "archive_head_hash TEXT NOT NULL DEFAULT '', " + "is_server BOOLEAN NOT NULL, " + "address_format_type INTEGER NOT NULL, " + "address_format_version INTEGER NOT NULL, " + @@ -962,6 +870,9 @@ public final class PostgresStorageRepository " OR normalized_login <> LOWER(BTRIM(login))" ); + statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''"); + statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''"); + statement.executeUpdate( "CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " + "ON solana_user_pda_current(slot)" @@ -995,6 +906,8 @@ public final class PostgresStorageRepository "last_block_hash TEXT NOT NULL, " + "last_block_signature TEXT NOT NULL, " + "arweave_tx_id TEXT NOT NULL, " + + "archive_head_tx_id TEXT NOT NULL DEFAULT '', " + + "archive_head_hash TEXT NOT NULL DEFAULT '', " + "is_server BOOLEAN NOT NULL, " + "address_format_type INTEGER NOT NULL, " + "address_format_version INTEGER NOT NULL, " + @@ -1014,6 +927,9 @@ public final class PostgresStorageRepository ")" ); + statement.executeUpdate("ALTER TABLE solana_user_pda_history ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''"); + statement.executeUpdate("ALTER TABLE solana_user_pda_history ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''"); + statement.executeUpdate( "CREATE INDEX IF NOT EXISTS idx_user_pda_history_login " + "ON solana_user_pda_history(login)" diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java index af065063..5e5fd518 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java @@ -87,7 +87,7 @@ public final class SolanaPdaUtil { return false; } - return Ed25519.validatePublicKeyFull( + return Ed25519.validatePublicKeyPartial( publicKey, 0 ); diff --git a/SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/util/SolanaPdaUtilTest.java b/SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/util/SolanaPdaUtilTest.java new file mode 100644 index 00000000..55fe85a9 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/util/SolanaPdaUtilTest.java @@ -0,0 +1,27 @@ +package sync.util; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SolanaPdaUtilTest { + + @Test + void findProgramAddressMatchesSolanaRuntimeForUserLoginSeed() { + String pda = SolanaPdaUtil.findProgramAddress( + List.of( + "user_login=".getBytes(StandardCharsets.UTF_8), + "server_t2".getBytes(StandardCharsets.UTF_8) + ), + "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6" + ); + + assertEquals( + "EJCW7cPXgEMvQAzmhJMZ7pxQxxxFbQYZoJNhcfJpWnz8", + pda + ); + } +} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 87f30e7d..7e76d99d 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -5,6 +5,7 @@ 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.debug.DebugApiConfigurator; import server.files.DmFileApiConfigurator; import server.sync.BlockchainResyncRecoveryOnStartup; @@ -82,6 +83,9 @@ public final class WsServer { // ============================================================ PeriodicBlockchainSyncService.startOrLog(); + // Опциональная серверная публикация больших SHINE-ARCHIVE блоков. + ArchivePublisherScheduler.startOrLog(); + // ============================================================ // 2) Запуск Jetty WS // ============================================================ @@ -110,7 +114,7 @@ public final class WsServer { log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); ServerConnectionPool.getInstance().startOrLog(); Runtime.getRuntime().addShutdownHook(new Thread( - () -> ServerConnectionPool.getInstance().close(), + () -> { ServerConnectionPool.getInstance().close(); ArchivePublisherScheduler.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 8c79392e..6ce8cf97 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -14,6 +14,34 @@ 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 ещё нет. diff --git a/build.gradle b/build.gradle index 28a0756f..278c8fd6 100644 --- a/build.gradle +++ b/build.gradle @@ -84,6 +84,7 @@ dependencies { implementation project(':shine-server-net-protocol') // Модуль отвечающий за протокол (классы Net..Request/Response implementation project(':shine-server-net-server') // Хэндлеры для обработки сетевых запросов implementation project(':shine-server-solana-users-sync') + implementation project(':shine-server-archive') diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 124eb013..07553d0b 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -14,8 +14,6 @@ | --- | --- | --- | | `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна | | `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу | -| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара | -| `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave | | `ResolveLoginForAuth` | `02_Authentication_API.md` | проверка login перед входом: LOCAL / REMOTE / NOT_FOUND / NO_ACCESS_SERVER + URL правильного access server | | `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии | | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | diff --git a/docs/Archive/01_PROTOCOL_v1.0.md b/docs/Archive/01_PROTOCOL_v1.0.md new file mode 100644 index 00000000..ce7950e2 --- /dev/null +++ b/docs/Archive/01_PROTOCOL_v1.0.md @@ -0,0 +1,1619 @@ +# 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` переводит локальные курсоры на новую точку. + +На первом этапе предполагается один архивный Raspberry-сервер, но формат изначально не должен запрещать появление других архиваторов. + +--- + +# 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. Необходимые ключи архивного сервера + +Архивный Raspberry/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/Archive/02_IMPLEMENTATION_MAP.md b/docs/Archive/02_IMPLEMENTATION_MAP.md new file mode 100644 index 00000000..f0654733 --- /dev/null +++ b/docs/Archive/02_IMPLEMENTATION_MAP.md @@ -0,0 +1,98 @@ +# Карта реализации 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`. diff --git a/docs/Archive/03_DEPLOY_TEST_SERVER.md b/docs/Archive/03_DEPLOY_TEST_SERVER.md new file mode 100644 index 00000000..5b738ad7 --- /dev/null +++ b/docs/Archive/03_DEPLOY_TEST_SERVER.md @@ -0,0 +1,415 @@ +# Деплой SHiNE Archive Publisher v1.0 на тестовый сервер + +Документ рассчитан на человека или автономного coding/deploy агента. Выполнять шаги по порядку. Не включать publisher до проверки Solana-программы и ключей. + +## 0. Что именно меняется + +Нужны изменения одновременно в: + +1. серверном Java-коде; +2. PostgreSQL schema v22; +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 v22 сделать backup тестовой БД. Например: + +```bash +pg_dump -Fc -d '' -f shine-before-archive-v22.dump +``` + +Точная команда зависит от текущей схемы доступа PostgreSQL. + +При старте сервер сам применит `migration_v22.sql`, если `db_schema_version < 22`. Вручную migration выполнять обычно не нужно. + +После старта проверить: + +```sql +SELECT * FROM db_schema_version WHERE id=1; +``` + +Ожидается: + +```text +schema_version = 22 +``` + +И наличие: + +```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` действительно обновлена этой версией. diff --git a/docs/Archive/04_TEST_AND_OPERATIONS.md b/docs/Archive/04_TEST_AND_OPERATIONS.md new file mode 100644 index 00000000..7666ec5c --- /dev/null +++ b/docs/Archive/04_TEST_AND_OPERATIONS.md @@ -0,0 +1,86 @@ +# Проверка и эксплуатация 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`. diff --git a/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md b/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md new file mode 100644 index 00000000..9e461b61 --- /dev/null +++ b/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md @@ -0,0 +1,60 @@ +# Состав patch-пакета и удаление legacy-файлов + +ZIP `SHINE_archive_changed_files_with_docs.zip` предназначен для распаковки **поверх исходного дерева той версии сервера, из которой он был сделан**. Внутри находятся только новые и изменённые файлы, пути сохранены относительно корня репозитория. + +## Важное ограничение ZIP-overlay + +Распаковка ZIP может добавить/заменить файлы, но не удалит старые. Поэтому после распаковки нужно удалить legacy test-free-avatar исходники ниже. Они больше не зарегистрированы в `JsonHandlerRegistry`, однако физическое удаление сохраняет дерево в точном состоянии новой версии и не оставляет старый тестовый Arweave-код рядом с production archive publisher. + +## Удалить после распаковки + +```text +SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java +SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java +docs/API/14_Test_Free_Avatar_Upload_API.md +``` + +Linux-команда из корня репозитория: + +```bash +rm -f \ + 'SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java' \ + 'SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java' \ + 'docs/API/14_Test_Free_Avatar_Upload_API.md' +``` + +## Что пакет принципиально добавляет + +- новый Gradle module `shine-server-archive`; +- migration/schema v22; +- archive state DAO/entities; +- server startup scheduler; +- Arweave chunk uploader; +- Solana User PDA writer; +- PDA block type `100` в Rust/Java/JS; +- обновлённую документацию формата User PDA; +- отдельную папку `docs/Archive/` с полным protocol/deploy/runbook. + +## После overlay + удаления + +Минимально проверить: + +```bash +./gradlew shadowJar +``` + +И отдельно собрать/задеплоить изменённую Solana `shine_users` согласно `03_DEPLOY_TEST_SERVER.md`. diff --git a/docs/Archive/06_FILE_MANIFEST.md b/docs/Archive/06_FILE_MANIFEST.md new file mode 100644 index 00000000..c2a81b85 --- /dev/null +++ b/docs/Archive/06_FILE_MANIFEST.md @@ -0,0 +1,73 @@ +# Manifest changed/new files + +Основа сравнения: последний исходный ZIP пользователя, на который рассчитан этот пакет. + +- Изменённых файлов: 20 +- Новых файлов до добавления этого manifest: 25 +- Удаляемых legacy-файлов: 10 + +## Изменённые файлы + +- `SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.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-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.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` +- `build.gradle` +- `docs/API/09_Operations_Index.md` +- `docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md` +- `docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md` +- `docs/Solana/user_pda/README.md` +- `docs/Solana_Architecture/details/shine_users.md` +- `settings.gradle` +- `shine-UI/js/services/auth-service.js` +- `shine-UI/js/services/shine-user-pda-service.js` +- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` +- `shine-solana/shine/doc/programs/shine_users.md` +- `shine-solana/shine/programs/shine_users/src/lib.rs` + +## Новые файлы + +- `SHiNE-server/shine-server-archive/build.gradle` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java` +- `SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java` +- `SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java` +- `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql` +- `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/README.md` +- `docs/Archive/archive-publisher.example.properties` + +## Legacy-файлы, которые ZIP сам не удаляет + +- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java` +- `docs/API/14_Test_Free_Avatar_Upload_API.md` + +Подробная команда удаления находится в `05_PATCH_CONTENTS_AND_REMOVALS.md`. diff --git a/docs/Archive/README.md b/docs/Archive/README.md new file mode 100644 index 00000000..743c9bf8 --- /dev/null +++ b/docs/Archive/README.md @@ -0,0 +1,28 @@ +# 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. `archive-publisher.example.properties` — минимальный конфиг архиватора. + +## Коротко + +- Архиватор **по умолчанию выключен**: `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` ещё не обновлена кодом из этого пакета.** Сначала обновить программу на нужном кластере, затем сервер. diff --git a/docs/Archive/archive-publisher.example.properties b/docs/Archive/archive-publisher.example.properties new file mode 100644 index 00000000..f9190f38 --- /dev/null +++ b/docs/Archive/archive-publisher.example.properties @@ -0,0 +1,23 @@ +# Минимальный пример для 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 diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md index 52a0afd2..ce7950e2 100644 --- a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md +++ b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md @@ -1,113 +1,333 @@ # SHiNE Archive Protocol v1.0 -## Полная русская спецификация серверной фиксации больших блоков, Arweave, Solana User PDA и навигации по предыдущим записям пользователя +## Полная спецификация серверной архивации SHiNE-блоков в Arweave и фиксации головы архива в Solana -**Статус:** рабочая спецификация v1.0 -**Формат:** бинарный +**Статус:** согласованная спецификация v1.0 +**Язык:** русский **Magic:** `SHINE-ARCHIVE` -**Версия:** два отдельных байта `major` / `minor` +**Версия протокола:** `1.0` +**Endian:** big-endian для всех фиксированных целых чисел --- # 1. Назначение -Протокол предназначен для периодической долговременной фиксации новых SHiNE-блоков. +Этот документ описывает механизм, по которому 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` переводит локальные курсоры на новую точку. + +На первом этапе предполагается один архивный Raspberry-сервер, но формат изначально не должен запрещать появление других архиваторов. + +--- + +# 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 -Основной SHiNE-сервер - | - | существующая межсерверная синхронизация - v -Raspberry / архивный SHiNE-сервер - | - +--> видит все новые локальные SHiNE-блоки - +--> фиксирует стабильную дельту - +--> собирает большой SHINE-ARCHIVE блок - +--> загружает его в Arweave - +--> ждёт закрепления Arweave-транзакции - +--> обычным update своего User PDA записывает новый archive head +BigBlock 70: + AliceChunk = 2 записи + +BigBlock 81: + AliceChunk = 5 записей + +BigBlock 95: + AliceChunk = 3 записи + +BigBlock 100: + AliceChunk = 4 записи ``` -Существующую межсерверную синхронизацию в v1 менять не требуется. +Тогда история `alice-001` выглядит: -Архивный сервер не создаёт и не переподписывает пользовательские сообщения. Он берёт уже существующие пользовательские записи и добавляет только серверную навигационную метаинформацию большого блока. +```text +BigBlock 100 + AliceChunk + 4 записи + | + +--> PreviousBlockchainChunkRef + | + v +BigBlock 95 + AliceChunk + 3 записи + | + +--> PreviousBlockchainChunkRef + | + v +BigBlock 81 + AliceChunk + 5 записей + | + v +BigBlock 70 +``` + +Навигация идёт пачками записей одной blockchain, а не по одной записи за раз. --- -# 2. Основные принципы +# 4. Сервер сам добавляет навигацию -1. Публикуется только новая дельта. -2. Дельта замораживается до начала загрузки. -3. Новые данные, пришедшие во время публикации, попадают в следующий большой блок. -4. После загрузки сервер ждёт закрепления Arweave-транзакции. -5. Только после закрепления Arweave сервер обновляет свой User PDA в Solana. -6. Только после `finalized` в Solana локальные курсоры считаются окончательно сдвинутыми. -7. Большой блок имеет собственный SHA-256 и подпись составившего его SHiNE-аккаунта. -8. Каждый большой блок содержит список предыдущих больших блоков с номером, hash и Arweave TX ID. -9. В v1 в начало каждого нового большого блока записывается полный список всех известных предыдущих больших блоков данной ветки. -10. Для каждого пользователя сервер строит обратную цепочку не на одну предыдущую запись, а на **предыдущий большой блок, где были записи этого пользователя, плюс список всех его записей внутри того блока**. +Пользователь не обязан знать: + +- в какой большой архивный блок попадут его записи; +- какой будет Arweave TX ID; +- какие будут offsets; +- где находится предыдущий chunk. + +Все эти данные сервер добавляет при формировании большого блока. + +Исходные подписанные пользовательские `block_bytes` остаются неизменными. + +`PreviousBlockchainChunkRef` является внешней серверной архивной метаинформацией и защищается hash + подписью всего большого архивного блока. --- -# 3. Ключи архивного сервера +# 5. Необходимые ключи архивного сервера -Для первой реализации серверу требуются: +Архивный Raspberry/server хранит: -- `root private key` SHiNE-аккаунта; -- `client private key` SHiNE-аккаунта; -- Arweave JWK/private wallet key. +## 5.1. SHiNE root private key -`blockchain private key` для самой архивной публикации не требуется: пользовательские записи уже существуют и уже подписаны. +Используется: -В v1 большой блок может подписываться root key того SHiNE-аккаунта, который его сформировал. +- для подписания новой версии 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-блоки. --- -# 4. Локальное состояние дельты +# 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. текущую замороженную дельту, которая ещё проходит Arweave/Solana. +2. текущий замороженный job, который ещё проходит Arweave/Solana. -Рекомендуются три таблицы. +Нельзя считать дельту от живой головы во время уже запущенной публикации. -## 4.1. `archive_chain_cursor` +--- + +# 8. Таблица `archive_chain_cursor` + +Хранит окончательно подтверждённое архивное состояние для каждой `blockchain_name`. ```sql CREATE TABLE archive_chain_cursor ( - blockchain_name TEXT PRIMARY KEY, - last_archived_block_number BIGINT NOT NULL, - last_archived_block_hash BYTEA NOT NULL, - updated_at_ms BIGINT NOT NULL + 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 ); ``` -Она хранит точку, до которой конкретная локальная blockchain уже окончательно вошла в опубликованный и зафиксированный archive head. +Назначение: -## 4.2. `archive_publish_job` +- `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, - archive_number BIGINT NOT NULL, - status TEXT NOT NULL, - parent_arweave_tx_id BYTEA, - parent_archive_hash BYTEA, - 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 + 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 @@ -120,716 +340,675 @@ CURSORS_COMMITTED FAILED ``` -## 4.3. `archive_publish_job_chain` +Завершённые 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_block_number BIGINT NOT NULL, - to_block_number BIGINT NOT NULL, - previous_block_hash BYTEA NOT NULL, - last_block_hash BYTEA NOT NULL, + 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) ); ``` -Сырые блоки в staging-таблицы копировать не нужно. В job фиксируются только точные диапазоны и крайние hash. - --- -# 5. Заморозка дельты +# 12. Заморозка дельты Пример: ```text -локально сейчас: -Alice = 180 -Bob = 94 +локально: +alice-001 = source block 180 +bob-001 = source block 94 -уже опубликовано: -Alice = 160 -Bob = 90 +опубликовано: +alice-001 = 160 +bob-001 = 90 ``` Новый job фиксирует: ```text -Alice: 161..180 -Bob: 91..94 +alice-001: 161..180 +bob-001: 91..94 ``` -Если во время Arweave upload локальное состояние стало: +Если во время загрузки появятся: ```text -Alice = 185 -Bob = 97 +alice-001 181..185 +bob-001 95..97 ``` -текущий job НЕ меняется. - -Следующая публикация начнётся с: - -```text -Alice: 181..185 -Bob: 95..97 -``` +они попадут только в следующий job. --- -# 6. Общая структура большого блока +# 13. Проверка курсора перед публикацией -Большой опубликованный файл одновременно является: - -- архивной дельтой; -- индексом предыдущих больших блоков; -- контейнером пользовательских записей; -- навигационным узлом для истории каждого пользователя; -- подписанным объектом того SHiNE-аккаунта, который его сформировал. - -Структура: +Перед созданием frozen range сервер проверяет: ```text -+------------------------------------------------+ -| PREFIX / HEADER | -| magic = SHINE-ARCHIVE | -| version major/minor | -| размеры/счётчики | -| номер большого блока | -| время | -| LOGIN составителя | -| режим таблицы предыдущих блоков | -| число предыдущих блоков | -+------------------------------------------------+ -| PREVIOUS BLOCK REFERENCES TABLE | -| | -| block_number + block_hash + arweave_tx_id | -| block_number + block_hash + arweave_tx_id | -| ... | -+------------------------------------------------+ -| USER RECORDS / SERVER NAVIGATION METADATA | -| ... | -+------------------------------------------------+ -| FOOTER | -| LOGIN закрывшего блок | -| block_hash | -| signature | -+------------------------------------------------+ +local hash(last_archived_source_block_number) +== +archive_chain_cursor.last_archived_source_block_hash ``` -Логин в header и footer должен быть одинаковым. - -Дублирование логина намеренное: - -- header позволяет сразу при скачивании начала файла увидеть, кто составил блок; -- footer делает закрытие блока человекочитаемым рядом с hash и подписью. +При несовпадении публикация этой chain останавливается до resync/fork handling. --- -# 7. Magic и версия +# 14. Ограничение размера большого блока -Magic: +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 ASCII-байт: +Размер: 13 байт. -```text -53 48 49 4E 45 2D 41 52 43 48 49 56 45 -``` - -Версия: +Далее: ```text u8 version_major u8 version_minor ``` -Для этой спецификации: +Для v1.0: ```text 1 0 ``` -то есть `v1.0`. - --- -# 8. Порядок байтов - -Все fixed-size integer-поля v1 хранятся в: +# 16. Общий layout большого блока ```text -big-endian ++------------------------------------------+ +| FIXED HEADER | ++------------------------------------------+ +| creator_login | ++------------------------------------------+ +| PREVIOUS BIG BLOCK REFERENCES | +| ref[0] | +| ref[1] | +| ... | ++------------------------------------------+ +| UserBlockchainChunk #1 | +| UserBlockchainChunk #2 | +| ... | ++------------------------------------------+ +| FOOTER: closer_login | +| block_hash | +| signature | ++------------------------------------------+ ``` --- -# 9. Header большого блока v1.0 +# 17. Fixed Header v1.0 -Header должен быть устроен так, чтобы клиент мог сначала скачать только небольшой префикс файла, узнать размер всей начальной индексной области и затем докачать только её. +Все 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 -bytes[13] magic = "SHINE-ARCHIVE" - -u8 version_major -u8 version_minor - -u32 header_size -u32 block_number -u64 created_at_ms - -u8 creator_login_length -bytes[N] creator_login UTF-8 - -u8 references_mode -u32 references_count -u16 reference_entry_size - -u32 records_count +u8 creator_login_length +bytes[N] creator_login UTF-8 ``` -## 9.1. `header_size` +Этот login находится в начале файла, поэтому его можно узнать без скачивания payload. -`header_size` — абсолютный byte offset от начала файла до первого байта области пользовательских записей. +--- -То есть включает: - -```text -header prefix -+ creator_login -+ всю таблицу PreviousBlockReference -``` - -Клиент может: - -1. скачать первые условные 64–128 байт; -2. прочитать `header_size`; -3. докачать `0 .. header_size-1`; -4. уже иметь все адреса предыдущих больших блоков; -5. после этого скачивать только нужные user records. - -## 9.2. `block_number` +# 19. `big_block_number` ```text u32 ``` -Номер большого итогового блока данной ветки. - -Диапазон: +Рекомендуемая нумерация опубликованных больших блоков: ```text -0 .. 4 294 967 295 +first = 1 +next = 2 +next = 3 +... ``` -Для предполагаемой частоты закрытия это более чем достаточный запас. +Первый реально публикуемый большой блок имеет номер `1`, поэтому его локальное имя содержит `00001`. Неудачная незавершённая попытка не становится частью опубликованной archive-цепочки. -## 9.3. `created_at_ms` +--- + +# 20. `created_at_ms` ```text u64 ``` -Unix Epoch UTC в миллисекундах. +Unix Epoch UTC milliseconds. -Это время фиксации snapshot текущего большого блока. +Это время заморозки содержимого big block. -## 9.4. `creator_login` +--- -Логин SHiNE-пользователя/сервера, который сформировал этот большой блок. +# 21. Режим ссылок на предыдущие большие блоки + +```text +references_mode u8 +``` + +Значения: + +```text +0 = FULL +1 = PARTIAL, зарезервировано на будущее +``` + +Writer v1.0 MUST использовать `FULL`. + +--- + +# 22. FULL previous-block table + +Новый big block содержит ссылки на ВСЕ предыдущие большие блоки своей ветки. Пример: ```text -raspberry-archive +BigBlock #365 + +References: +#1 +#2 +#3 +... +#364 ``` -Это поле находится именно в header, поэтому клиент видит автора уже после скачивания начала блока. +При одном big block в день через год таблица занимает примерно 24.8 KiB. -`creator_login` входит в hash большого блока и тем самым криптографически привязан к его подписи. +--- -## 9.5. `references_mode` +# 23. `PreviousBigBlockReference` + +Фиксированный размер: 68 байт. + +| Поле | Тип | Размер | +|---|---:|---:| +| big_block_number | u32 | 4 | +| big_block_hash | bytes[32] | 32 | +| arweave_tx_id | bytes[32] | 32 | + +Семантика: ```text -u8 +big_block_number = логический номер +big_block_hash = криптографическая идентичность +arweave_tx_id = где скачать блок ``` -Зарезервированные значения: +--- -```text -0 = FULL_HISTORY -1 = PARTIAL_HISTORY -``` +# 24. `references_count` -Для v1 publisher MUST использовать: - -```text -FULL_HISTORY -``` - -То есть каждый новый большой блок содержит ссылки на все известные предыдущие большие блоки своей ветки. - -`PARTIAL_HISTORY` резервируется на будущее, если когда-нибудь полный список станет слишком большим. - -## 9.6. `references_count` +Количество entries в таблице ссылок. ```text u32 ``` -Количество элементов в таблице предыдущих больших блоков. +--- -## 9.7. `reference_entry_size` +# 25. `reference_entry_size` ```text u16 ``` -Для v1: +Для v1.0: ```text 68 ``` -Поле оставлено явно, чтобы будущая minor/major версия могла расширить `PreviousBlockReference`, а клиент всё ещё мог корректно пропускать неизвестные дополнительные байты. +Поле оставлено для будущего расширения entry. -## 9.8. `records_count` +--- + +# 26. `parent_reference_index` ```text u32 ``` -Количество пользовательских record-контейнеров внутри большого блока. +Индекс непосредственного родителя внутри reference table. ---- - -# 10. Таблица всех предыдущих больших блоков - -Сразу после header идёт: - -```text -PreviousBlockReference[references_count] -``` - -В v1 это **полная история предыдущих больших блоков данной ветки**. - -При одном закрытии в сутки даже через год это около 365 элементов, поэтому простота полного списка важнее преждевременной оптимизации. - ---- - -# 11. `PreviousBlockReference v1` - -Формат одного элемента: - -```text -u32 block_number -bytes[32] block_hash -bytes[32] arweave_tx_id -``` - -Размер: - -```text -4 + 32 + 32 = 68 байт -``` - -Смысл: - -```text -block_number --> логический номер большого блока - -block_hash --> SHA-256 / канонический hash точного содержимого большого блока - -arweave_tx_id --> raw 32-byte TX ID, по которому этот большой блок можно скачать из Arweave -``` - -То есть: - -```text -HASH = идентичность содержимого -TXID = место хранения -``` - -Оба поля обязательны. - -Внешнее строковое Base64URL-представление TX ID внутрь бинарного файла не записывается. - -Таблица v1 SHOULD быть отсортирована детерминированно по `block_number`, затем по `block_hash`. - ---- - -# 12. Почему в v1 записываются все предыдущие блоки - -Если закрывать примерно один большой блок в сутки: - -```text -365 × 68 ≈ 24.8 KB в год -``` - -Даже через много лет это остаётся небольшим overhead по сравнению с полезными данными. - -Главное преимущество: любой новый большой блок сразу является индексом всей предыдущей ветки. - -Клиент не обязан идти: - -```text -365 -> 364 -> 363 -> ... -> 95 -``` - -Чтобы получить адрес блока 95. - -Он получает его из начала текущего блока. - ---- - -# 13. Пользовательская обратная навигация - -Навигация пользователя строится не на одну предыдущую запись. - -Правило v1: - -> Для каждого пользователя сервер находит **предыдущий большой блок**, в котором существовала хотя бы одна запись этого пользователя, и сохраняет ссылку на этот большой блок вместе со списком `offset + size` **всех записей этого пользователя в том предыдущем большом блоке**. - -Например: - -```text -Большой block 100: -Alice сегодня имеет 2 записи. - -Предыдущий большой блок, где Alice присутствовала: -block 95. - -В block 95 у Alice было 3 записи: - offset 8123 / size 417 - offset 9001 / size 351 - offset 14020 / size 692 -``` - -Тогда server-generated ссылка Alice из block 100 содержит все три диапазона. - ---- - -# 14. `PreviousUserRecordsRef v1` - -Формат: - -```text -u32 previous_block_ref -u32 previous_records_count - -repeat previous_records_count times: - u32 previous_record_offset - u32 previous_record_size -``` - -Минимальный размер: - -```text -8 байт -``` - -Плюс: - -```text -8 байт на каждую предыдущую запись пользователя -``` - -Пример для 3 сообщений: - -```text -previous_block_ref = 94 -previous_records_count = 3 - -8123 417 -9001 351 -14020 692 -``` - -Если `PreviousBlockReference[94]` описывает big block 95, клиент получает: - -```text -block number = 95 -block hash = ... -Arweave TXID = ... -``` - -и может HTTP Range-запросами забрать только три нужных диапазона. - ---- - -# 15. `previous_block_ref` - -`previous_block_ref` — индекс в таблице `PreviousBlockReference` **текущего большого блока**. - -То есть пользовательская ссылка не повторяет 32-byte TX ID и 32-byte hash. - -Она хранит только компактный индекс. - -Специальное значение: +Для первого большого блока (`#1`), у которого нет родителя: ```text 0xFFFFFFFF ``` -означает: - -```text -у пользователя ещё не было записей в предыдущих больших блоках -``` - -В этом случае: - -```text -previous_records_count = 0 -``` +В простой FULL-линейной ветке обычно это последний entry. --- -# 16. Смещение и размер записи +# 27. Быстрое скачивание начала блока -`previous_record_offset`: +Клиент сначала скачивает fixed header + creator login. + +Из него узнаёт: + +```text +header_size +references_count +reference_entry_size +``` + +Затем скачивает только reference table и уже имеет адреса/hash всех предыдущих больших блоков. + +Payload пользователей можно пока не скачивать. + +--- + +# 28. `blockchain_chunks_count` ```text u32 ``` -Абсолютный offset от первого байта соответствующего большого `SHINE-ARCHIVE` файла до первого байта нужной архивированной user-record. +Количество `UserBlockchainChunk` в текущем big block. -`previous_record_size`: +Одна `blockchain_name` встречается максимум один раз. + +--- + +# 29. `total_user_records_count` ```text u32 ``` -Полный размер этой архивированной user-record в байтах. - -Таким образом, зная: - -```text -TX ID -block hash -offset -size -``` - -клиент может скачать ровно нужную пользовательскую запись. +Общее количество исходных SHiNE-записей во всех chunks. --- -# 17. Как сервер добавляет пользовательскую ссылку +# 30. Порядок chunks и records -`PreviousUserRecordsRef` создаёт **сервер, который формирует большой блок**, а не пользовательский клиент. +Для детерминированной сериализации chunks SHOULD сортироваться по `blockchain_name ASC`. -Оригинальные подписанные пользователем bytes изменять нельзя. +Внутри chunk исходные записи MUST идти по возрастанию номера исходного SHiNE-блока. -Поэтому server navigation metadata хранится как часть контейнера большого блока вокруг/после оригинальных user-record bytes и покрывается: +--- -- `block_hash` большого блока; -- подписью сервера/пользователя, который закрыл большой блок. - -Для каждого пользователя в текущем большом блоке достаточно одного `PreviousUserRecordsRef`, связанного с последней записью этого пользователя в текущем блоке. - -Если у пользователя сегодня в текущем большом блоке две записи, сервер после их сериализации добавляет один navigation tail, который указывает на предыдущий большой блок пользователя и перечисляет все его записи там. - -При чтении предыдущего большого блока та же схема даёт следующий переход назад. - -Получается: +# 31. Формат `UserBlockchainChunk` ```text -Alice in block 100 - | - | PreviousUserRecordsRef - | -> block 95 - | -> offsets of ALL Alice records in block 95 - v -Alice records in block 95 - | - | server navigation metadata из block 95 - | -> block 81 - | -> offsets of ALL Alice records in block 81 - v -... +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 ``` --- -# 18. Рекомендуемый контейнер архивированной пользовательской записи - -Чтобы сервер мог добавлять свою навигационную метаинформацию, не изменяя исходную пользовательскую подпись, v1 SHOULD использовать серверный envelope. - -Рекомендуемая модель: +# 32. `chunk_size` ```text -u32 archived_record_size -u32 raw_user_record_size -bytes[N] raw_user_record -u8 navigation_flags -[optional server navigation data] +u32 ``` -Где: +Полный размер chunk от первого байта `chunk_size` до последнего байта `previous_chunk_size` включительно. -```text -navigation_flags bit 0 = PreviousUserRecordsRef присутствует -``` +Это позволяет: -У большинства записей: - -```text -navigation_flags = 0 -``` - -У последней записи конкретного пользователя в текущем большом блоке: - -```text -navigation_flags bit0 = 1 -``` - -и далее сериализован `PreviousUserRecordsRef`. - -`archived_record_size` позволяет Range-reader получить и полностью декодировать конкретный archived record. - -Исходный `raw_user_record` остаётся байт-в-байт неизменным. +- быстро пропустить chunk; +- скачать его Range-запросом; +- сохранить его точные координаты для следующего backlink. --- -# 19. Пример пользовательской цепочки +# 33. `blockchain_name` внутри chunk -```text -BIG BLOCK 100 +Хранится только `blockchain_name`. -Alice record A -Alice record B + PreviousUserRecordsRef: - previous_block_ref -> block 95 - previous_records_count = 3 - [8123,417] - [9001,351] - [14020,692] - - | - v - -BIG BLOCK 95 - -Alice old record #1 -Alice old record #2 -Alice old record #3 + PreviousUserRecordsRef: - previous_block_ref -> block 81 - previous_records_count = 1 - [4412,380] - - | - v - -BIG BLOCK 81 -... -``` - -Так клиент получает всю историю пользователя назад блок за блоком, но скачивает только конкретные нужные byte ranges. +Отдельный `login` не нужен. --- -# 20. Hash большого блока - -В footer записывается: +# 34. `records_count` ```text -bytes[32] block_hash +u32 ``` -`block_hash` вычисляется как: +Если за период у `alice-001` появилось 7 записей: ```text -SHA256( - все байты большого блока - от magic - через header - через creator_login - через PreviousBlockReference table - через все user records и server navigation metadata - через footer closer_login_length + closer_login -) +records_count = 7 ``` -Само поле `block_hash` и `closer_signature` в hash НЕ входят. +Все 7 находятся в одном chunk. --- -# 21. Footer: кто закрыл блок, hash и подпись +# 35. Raw records -В конце каждого большого блока MUST быть: +Каждая исходная запись сериализуется: + +```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 -1. кто сформировал/закрыл блок; -2. hash точных bytes блока; -3. подпись закрывающего аккаунта. +closer_login MUST == creator_login ``` -`closer_login` MUST точно совпадать с `creator_login` из header. +Login повторяется намеренно: -Если значения отличаются — блок невалиден. +- header даёт автора сразу; +- footer явно фиксирует того, кто закрыл блок; +- hash покрывает footer login; +- verifier проверяет совпадение. --- -# 22. Подпись большого блока - -Алгоритм v1: +# 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 ``` -Подписываемый payload: +Подписывается: ```text ASCII("SHINE-ARCHIVE-V1") || block_hash ``` -То есть: - -```text -closer_signature = -Ed25519Sign( - closer_private_key, - "SHINE-ARCHIVE-V1" || block_hash -) -``` - -Для первой реализации `closer_private_key` может быть root private key SHiNE-аккаунта `creator_login` / `closer_login`. - -Public key для проверки берётся из соответствующего User PDA в Solana. +Для v1.0 используется root private key аккаунта `creator_login`. --- -# 23. Archive head в Solana User PDA +# 48. Проверка подписи -Отдельный PDA создавать не требуется. +Verifier: -Используется тот же существующий User PDA. +1. читает `creator_login`; +2. проверяет `creator_login == closer_login`; +3. получает root public key аккаунта из User PDA / исторического состояния Solana; +4. пересчитывает `block_hash`; +5. проверяет Ed25519 signature. -Новый block type: +Старые архивы должны оставаться проверяемыми после ротации root key, поэтому исторические PDA-состояния нужно сохранять/уметь получать. -```text -100 -``` +--- + +# 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-а. Формат: @@ -840,185 +1019,360 @@ 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 -66 байт -``` +1. Существующая server-to-server sync независимо обновляет локальные blockchains. -Solana тем самым хранит: +2. Scheduler запускает archive job. -```text -где находится текущая голова -> Arweave TX ID -какие exact bytes являются правильными -> SHA-256 +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. ``` --- -# 24. Обновление Solana — обычный User PDA update +# 53. Cursor commit -**Отдельная Solana instruction для archive head не требуется.** +Для каждой blockchain из job: -Архивный сервер использует существующий обычный механизм обновления собственного User PDA: +```text +last_archived_source_block_number = to_source_block_number +last_archived_source_block_hash = last_source_block_hash -1. читает текущий PDA; -2. сохраняет все существующие поля; -3. меняет/добавляет только block type `100`; -4. пересобирает новую PDA-запись; -5. выполняет существующую root-signature проверку; -6. `client key` подписывает/оплачивает Solana transaction; -7. ждёт `finalized`. +last_archive_big_block_number = current big_block_number +last_archive_big_block_hash = current block_hash -Важно: все новые Java/JS/Rust сериализаторы обязаны понимать block type `100` и сохранять его при обычных обновлениях PDA. +last_chunk_offset = new_chunk_offset +last_chunk_size = new_chunk_size +``` + +Следующая публикация сразу знает, куда должен вести backlink. --- -# 25. `TestFreeAvatarArweaveService` +# 54. Arweave service -Старый сервис больше не нужен как avatar test service. +Старый `TestFreeAvatarArweaveService` удаляется из активного протокола; archive publisher использует отдельный `ArweaveArchiveService`. -Его следует переименовать, например, в: +Его следует переделать/переименовать, например в: ```text ArweaveArchiveService ``` -и переделать только под серверную фиксацию. +Он должен: -Нужно оставить/переиспользовать: - -- JWK parsing; -- wallet address; -- reward calculation; -- balance check; -- RSA-PSS signing; -- Arweave HTTP. - -Удалить avatar-specific части: - -- PNG/JPEG/WebP проверки; -- avatar quota; -- лимит 128 KiB; -- avatar DAO; -- avatar-specific API. - -Новый сервис должен уметь: - -```text -publishArchive(...) -waitForConfirmation(...) -``` - -и корректно работать с большими/chunked uploads. - ---- - -# 26. Ожидание закрепления Arweave - -После получения TX ID сервер НЕ обновляет Solana сразу. - -Он ждёт заданное число подтверждений. +- использовать 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 ``` -Только после `ARWEAVE_CONFIRMED` разрешён update User PDA. - --- -# 27. Порядок публикации +# 55. Solana finalization -Строго: +После обычного `update_user_pda` сервер должен дождаться: ```text -1. Синхронизировать/иметь актуальные локальные блоки. - -2. Прочитать archive_chain_cursor. - -3. Зафиксировать snapshot новых диапазонов. - -4. Создать archive_publish_job. - -5. Собрать большой SHINE-ARCHIVE v1.0. - -6. В header записать creator_login. - -7. В начало блока записать FULL_HISTORY список всех предыдущих больших блоков: - block_number + hash + Arweave TX ID. - -8. Для каждого пользователя сформировать server navigation metadata: - previous big block + список всех offset/size его записей там. - -9. Посчитать block_hash. - -10. В footer записать closer_login, block_hash, signature. - -11. Загрузить файл в Arweave. - -12. Сохранить TX ID в publish job. - -13. Дождаться закрепления Arweave. - -14. Обычным User PDA update записать block type 100: - archive_tx_id + archive_hash. - -15. Дождаться Solana finalized. - -16. В одной локальной DB transaction сдвинуть archive_chain_cursor. - -17. Отметить job CURSORS_COMMITTED. +finalized ``` -Если новых данных нет — пустой большой блок создавать не нужно. +Только после этого разрешается сдвинуть локальные курсоры. --- -# 28. Crash recovery +# 56. Crash recovery -## Crash после snapshot +## После `SNAPSHOT_CREATED` -Продолжить с теми же frozen ranges. +Возобновить тот же frozen job. -## Crash после построения файла +## После `FILE_BUILT` -Использовать тот же файл и тот же hash. +Использовать тот же локальный файл. -## Crash после Arweave upload +## После `ARWEAVE_UPLOADED` -Не загружать второй раз. Использовать сохранённый TX ID. +Не загружать второй раз. Продолжить по сохранённому TX ID. -## Crash после Arweave confirmation +## После `ARWEAVE_CONFIRMED` -Продолжить с User PDA update. +Продолжить Solana update. -## Crash после Solana submit +## После `SOLANA_SUBMITTED` -Сначала проверить текущий PDA/transaction. +Сначала проверить transaction / текущий PDA. -## Crash после Solana finalized, но до cursor commit +## После `SOLANA_FINALIZED`, но до cursor commit -Если PDA уже содержит ожидаемые: +Проверить: ```text -archive_tx_id -archive_hash +PDA.archive_tx_id == expected_tx +PDA.archive_hash == expected_hash ``` -то безопасно выполнить локальный cursor commit. +После этого безопасно выполнить cursor commit. --- -# 29. Настройки scheduler +# 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.intervalMinutes=720 -archive.publish.initialDelayMinutes=15 +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 @@ -1029,324 +1383,237 @@ archive.arweave.confirmTimeoutMinutes=180 archive.solana.rootKeyPath=/opt/shine/secrets/root.key archive.solana.clientKeyPath=/opt/shine/secrets/client.key archive.solana.commitment=finalized -``` -Для первой версии разумно начать с: - -```text -720 минут = раз в 12 часов -``` - -Частота обычной межсерверной синхронизации меняется независимо. - ---- - -# 30. Проверка большого блока клиентом - -Клиент SHOULD выполнять: - -```text -1. Скачать начало файла. -2. Проверить magic. -3. Прочитать major/minor. -4. Прочитать header_size. -5. Прочитать creator_login. -6. Докачать весь header + PreviousBlockReference table. -7. При необходимости получить нужные previous block TXID/hash. -8. Скачать нужные user record ranges. -9. При полной проверке файла пересчитать block_hash. -10. Проверить closer_login == creator_login. -11. Получить public key автора из User PDA. -12. Проверить Ed25519 signature. +# Отдельного archive.solana.rpcUrl нет. +# Используется solana.users.sync.rpcUrl, а если он пуст — обычный solana.rpcUrl. ``` --- -# 31. Пример навигации Alice +# 69. Требование к `AppConfig` -Допустим клиент уже имеет актуальную запись Alice из block 100. +Archive settings должны читаться из: -Server navigation metadata говорит: +- Java system properties; +- environment variables; +- `application.properties`. -```text -previous_block_ref = 94 -previous_records_count = 3 - -[8123, 417] -[9001, 351] -[14020, 692] -``` - -Из начала block 100: - -```text -PreviousBlockReference[94]: - block_number = 95 - block_hash = H95 - arweave_tx_id = TX95 -``` - -Клиент: - -```text -1. идёт в TX95; -2. проверяет H95; -3. делает Range на 8123/417; -4. Range на 9001/351; -5. Range на 14020/692; -6. получает все три Alice records из block 95; -7. из server navigation metadata последней Alice record получает следующий переход, например на block 81; -8. повторяет. -``` - -То есть история читается: - -```text -100 -> 95 -> 81 -> 63 -> ... -``` - -но на каждом шаге скачиваются только конкретные записи данного пользователя. +`getInt()` и `getBoolean()` должны использовать общую логику `getParam()`. --- -# 32. Почему hash и TX ID нужны одновременно +# 70. Что переиспользуется из текущего сервера -Нельзя оставить только TX ID. - -Правильная модель: +Существующие механизмы: ```text -block_number --> логический номер +BlockchainStateDAO.listAll() +BlocksDAO.listRangeByNumber(...) +``` +дают необходимые heads и raw block ranges. + +Используются существующие: + +```text +block_bytes block_hash --> криптографическая идентичность - -arweave_tx_id --> текущее физическое расположение +block_signature +blockchain_name +block_number ``` -Если в будущем большой блок будет перенесён из Arweave в другое хранилище, его hash останется тем же. +Server-to-server sync в v1.0 не меняется. --- -# 33. Будущее `PARTIAL_HISTORY` +# 71. Блокировки -В первой реализации используется только: +Можно использовать существующий blockchain lock только на коротком этапе: ```text -references_mode = FULL_HISTORY +проверить cursor hash +прочитать frozen range +проверить конечный hash +сохранить/сериализовать frozen data ``` -На будущее уже зарезервирован: - -```text -PARTIAL_HISTORY -``` - -Будущая версия сможет, например, хранить: - -- последние N больших блоков; -- checkpoint-блоки; -- только реально используемые ссылки; -- многоуровневый индекс. - -Но это НЕ требуется для v1. +Lock не удерживается во время Arweave/Solana ожиданий. --- -# 34. Итоговые бинарные структуры v1.0 - -## Header - -```text -bytes[13] magic = "SHINE-ARCHIVE" -u8 version_major -u8 version_minor -u32 header_size -u32 block_number -u64 created_at_ms -u8 creator_login_length -bytes[N] creator_login -u8 references_mode -u32 references_count -u16 reference_entry_size -u32 records_count -``` - -## PreviousBlockReference - -```text -u32 block_number -bytes[32] block_hash -bytes[32] arweave_tx_id -``` - -Размер v1: - -```text -68 bytes -``` - -## PreviousUserRecordsRef - -```text -u32 previous_block_ref -u32 previous_records_count - -repeat previous_records_count: - u32 previous_record_offset - u32 previous_record_size -``` - -## User archived envelope — рекомендуемая форма - -```text -u32 archived_record_size -u32 raw_user_record_size -bytes[N] raw_user_record -u8 navigation_flags -[optional PreviousUserRecordsRef] -``` - -## Footer - -```text -u8 closer_login_length -bytes[N] closer_login -bytes[32] block_hash -bytes[64] closer_signature -``` - -## Solana User PDA block 100 - -```text -u8 block_type = 100 -u8 block_version = 0 -bytes[32] archive_tx_id -bytes[32] archive_hash -``` - ---- - -# 35. Обязательные проверки v1 - -Publisher MUST: - -- не изменять frozen job после snapshot; -- проверять cursor hash; -- не двигать cursor до Solana finalized; -- не публиковать пустую дельту; -- записывать полный список предыдущих больших блоков; -- хранить hash и TX ID каждого previous block; -- записывать creator login в header; -- повторять тот же login в footer; -- подписывать hash большого блока; -- добавлять для пользователя ссылку на предыдущий большой блок и offsets/sizes всех его записей там. - -Reader MUST: - -- проверять magic/version; -- проверять границы offsets; -- проверять hash скачанного большого блока; -- проверять `closer_login == creator_login`; -- проверять подпись; -- проверять, что скачанные user records действительно принадлежат ожидаемому пользователю; -- не считать Arweave TX ID самостоятельным доказательством корректности содержимого. - ---- - -# 36. Реализационный checklist - -## Solana - -- [ ] Добавить User PDA block type `100`. -- [ ] Добавить `archive_tx_id [32]`. -- [ ] Добавить `archive_hash [32]`. -- [ ] Научить обычный User PDA update читать и сохранять block 100. -- [ ] Серверу дать возможность обычным update менять block 100. -- [ ] Ждать `finalized`. +# 72. Реализационный checklist ## Database -- [ ] `archive_chain_cursor`. -- [ ] `archive_publish_job`. -- [ ] `archive_publish_job_chain`. +- [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 -- [ ] Переименовать `TestFreeAvatarArweaveService` в `ArweaveArchiveService`. -- [ ] Удалить avatar-specific код. -- [ ] Реализовать большие/chunked uploads. -- [ ] Реализовать ожидание confirmations. -- [ ] Сохранять TX ID до ожидания подтверждений. +- [x] rename/refactor `TestFreeAvatarArweaveService` +- [x] remove avatar-specific logic +- [x] large/chunked upload +- [x] confirmation polling -## Большой блок +## Solana -- [ ] Magic `SHINE-ARCHIVE`. -- [ ] `version_major u8`. -- [ ] `version_minor u8`. -- [ ] `header_size`. -- [ ] `block_number u32`. -- [ ] `created_at_ms u64`. -- [ ] `creator_login` в header. -- [ ] `FULL_HISTORY` previous block table. -- [ ] `block_number + block_hash + txid` для каждого previous block. -- [ ] Server-side `PreviousUserRecordsRef`. -- [ ] Список всех `offset + size` пользовательских записей из предыдущего big block. -- [ ] `closer_login + block_hash + signature` в footer. +- [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 / recovery +## Scheduler -- [ ] Настраиваемый interval. -- [ ] Initial delay. -- [ ] Один active job. -- [ ] Resume после restart. -- [ ] Cursor commit только после Solana finalized. +- [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 --- -# 37. Краткая итоговая модель +# 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 100 - archive TX ID - archive SHA-256 - - | - v - -SHINE-ARCHIVE block N - | - | HEADER: - | version - | block number - | time - | CREATOR LOGIN - | - | PREVIOUS BLOCK TABLE: - | #0 number/hash/TXID - | #1 number/hash/TXID - | ... все предыдущие в v1 - | - | USER DATA: - | original signed user records - | server navigation metadata - | previous block ref - | ALL previous user record offsets/sizes - | - | FOOTER: - | same creator/closer login - | hash - | Ed25519 signature + | block type 100 + | TX + HASH v - -SHINE-ARCHIVE block N-1 +BigBlock #100 | - v -... + +-- 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 ``` -Это завершает согласованную спецификацию `SHiNE Archive Protocol v1.0`. +--- + +# 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 index eb4db25a..ce7950e2 100644 --- a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md +++ b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md @@ -168,19 +168,78 @@ BigBlock 70 --- -# 6. Периодичность +# 6. Расписание публикации -Частота архивной публикации конфигурируется отдельно от существующей межсерверной синхронизации. +Архивная публикация запускается один раз в сутки в заданное локальное время. Она НЕ использует интервал «каждые N минут». Пример: ```properties archive.publish.enabled=true -archive.publish.intervalMinutes=720 -archive.publish.initialDelayMinutes=15 +archive.publish.time=00:00 +archive.publish.zoneId= ``` -`720` минут = раз в 12 часов. +Для 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 и завершает переименование без повторной сборки дельты. --- @@ -380,7 +439,7 @@ Offsets и sizes внутри `SHINE-ARCHIVE v1.0` используют `u32`. archive.maxFileBytes=4000000000 ``` -Если данных больше, один scheduler-run формирует несколько последовательных больших блоков. +Если собранный frozen job превышает этот лимит, v1.0 останавливает публикацию с явной ошибкой `ArchiveTooLargeException` и не двигает курсоры. Практически лимит очень велик; для такого сервера следует уменьшить объём данных между суточными закрытиями или реализовать деление snapshot на несколько big blocks. Автоматическое деление одного snapshot на несколько big blocks оставлено как совместимое будущее расширение. --- @@ -480,15 +539,17 @@ bytes[N] creator_login UTF-8 u32 ``` -Рекомендуемая нумерация: +Рекомендуемая нумерация опубликованных больших блоков: ```text -genesis = 0 -next = 1 -next = 2 +first = 1 +next = 2 +next = 3 ... ``` +Первый реально публикуемый большой блок имеет номер `1`, поэтому его локальное имя содержит `00001`. Неудачная незавершённая попытка не становится частью опубликованной archive-цепочки. + --- # 20. `created_at_ms` @@ -530,9 +591,9 @@ Writer v1.0 MUST использовать `FULL`. BigBlock #365 References: -#0 #1 #2 +#3 ... #364 ``` @@ -595,7 +656,7 @@ u32 Индекс непосредственного родителя внутри reference table. -Для genesis: +Для первого большого блока (`#1`), у которого нет родителя: ```text 0xFFFFFFFF @@ -1061,7 +1122,7 @@ last_chunk_size = new_chunk_size # 54. Arweave service -Старый `TestFreeAvatarArweaveService` больше не нужен как avatar-specific сервис. +Старый `TestFreeAvatarArweaveService` удаляется из активного протокола; archive publisher использует отдельный `ArweaveArchiveService`. Его следует переделать/переименовать, например в: @@ -1307,8 +1368,9 @@ derivePublic(client_private) == UserPDA.client_key ```properties archive.publish.enabled=false -archive.publish.intervalMinutes=720 -archive.publish.initialDelayMinutes=15 +archive.publish.time=00:00 +archive.publish.zoneId= +archive.workDir=data/archive archive.maxFileBytes=4000000000 @@ -1321,6 +1383,9 @@ 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. ``` --- @@ -1381,54 +1446,55 @@ Lock не удерживается во время Arweave/Solana ожидани ## Database -- [ ] `archive_chain_cursor` -- [ ] `archive_publish_job` -- [ ] `archive_publish_job_chain` -- [ ] schema migration -- [ ] crash recovery +- [x] `archive_chain_cursor` +- [x] `archive_publish_job` +- [x] `archive_publish_job_chain` +- [x] schema migration +- [x] crash recovery ## Archive writer -- [ ] magic `SHINE-ARCHIVE` -- [ ] major/minor version -- [ ] fixed header -- [ ] creator login -- [ ] FULL previous big block table -- [ ] one chunk per `blockchain_name` -- [ ] many raw records inside one chunk -- [ ] one backlink per chunk -- [ ] closer login -- [ ] SHA-256 -- [ ] Ed25519 signature -- [ ] max file size < 4 GiB +- [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 -- [ ] rename/refactor `TestFreeAvatarArweaveService` -- [ ] remove avatar-specific logic -- [ ] large/chunked upload -- [ ] confirmation polling +- [x] rename/refactor `TestFreeAvatarArweaveService` +- [x] remove avatar-specific logic +- [x] large/chunked upload +- [x] confirmation polling ## Solana -- [ ] add PDA block type `100` -- [ ] update Rust codec -- [ ] update Java codec -- [ ] update JS codec/UI writer -- [ ] use ordinary `update_user_pda` -- [ ] server-side transaction writer -- [ ] root signature -- [ ] client fee payer -- [ ] wait for `finalized` +- [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 -- [ ] `archive.publish.enabled` -- [ ] `archive.publish.intervalMinutes` -- [ ] `archive.publish.initialDelayMinutes` -- [ ] no concurrent jobs -- [ ] split files > max size -- [ ] skip when no new blocks +- [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 --- @@ -1506,10 +1572,10 @@ BigBlock #100 | creator = server-A | +-- References - | #0 -> BigBlock #0 / hash / TX - | #1 -> BigBlock #1 / hash / TX + | ref[0] -> BigBlock #1 / hash / TX + | ref[1] -> BigBlock #2 / hash / TX | ... - | #99 -> BigBlock #99 / hash / TX + | ref[98] -> BigBlock #99 / hash / TX | +-- alice-001 chunk | records x4 diff --git a/docs/Solana/user_pda/README.md b/docs/Solana/user_pda/README.md index 877a827f..ac2e64c0 100644 --- a/docs/Solana/user_pda/README.md +++ b/docs/Solana/user_pda/README.md @@ -94,16 +94,17 @@ UserPdaRecordV1 | `40` | `AccessServersBlock` | Серверы доступа/relay. | | `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. | | `70` | `TrustedStateBlock` | Счетчик trusted-связей. | +| `100` | `ArchiveHeadBlock` | Текущая голова серверного SHINE-ARCHIVE: Arweave TX ID + SHA-256 архива. | | `255` | `ReservedBlock` | Зарезервировано, пока не используется. | Правила: - неизвестный `block_type` в `format_major = 1` считается ошибкой; - обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`; -- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`; +- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`; - каждый обязательный блок должен встречаться ровно один раз; - порядок блоков в записи фиксируется для простоты проверки: - `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`. + `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`, `ArchiveHead`. ## 6. RecoveryKeyBlock @@ -359,6 +360,28 @@ TrustedStateBlock Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат. +## 15.1. ArchiveHeadBlock + +Необязательный блок текущей головы серверного архива. Он используется archive-capable сервером и хранится в том же User PDA. + +```text +ArchiveHeadBlock +- block_type: u8 = 100 +- block_version: u8 = 0 +- archive_tx_id: [u8; 32] +- archive_hash: [u8; 32] +``` + +Семантика: + +- `archive_tx_id` — raw 32-byte Arweave transaction id последнего опубликованного большого `SHINE-ARCHIVE`; текстовая Base64URL-форма получается вне PDA; +- `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 без изменений**; +- расширенный `update_user_pda` может заменить archive head или явно очистить его; отдельной Solana instruction для архива нет. + +`ArchiveHeadBlock` входит в unsigned bytes User PDA и тем самым покрывается обычной root-подписью записи. + ## 16. Подпись user_pda Подписывается не вся PDA целиком, а unsigned-часть записи: @@ -392,6 +415,7 @@ Solana-программа проверяет подпись через встр - обязательные блоки присутствуют; - создается минимум один `BlockchainRecord`; - новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`; +- `ArchiveHeadBlock` при регистрации не обязателен; обычный пользователь/сервер может начать публиковать архив позже; - стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит; - `used_bytes <= paid_limit_bytes`; - пользователь платит регистрационную комиссию; @@ -408,6 +432,7 @@ Solana-программа проверяет подпись через встр - `prev_record_hash` равен хэшу unsigned-части предыдущей записи; - `updated_at_ms` обновляется; - unsigned-часть новой записи подписана `root_key`; +- если archive extension в instruction отсутствует (legacy client), старый `ArchiveHeadBlock` сохраняется; если extension присутствует, применяется переданное `archive_head_update`; - лимиты блокчейнов могут только увеличиваться; - занятый размер и номер последнего блока не могут уменьшаться; - при увеличении оплаченного лимита пользователь доплачивает комиссию; diff --git a/docs/Solana_Architecture/details/shine_users.md b/docs/Solana_Architecture/details/shine_users.md index aa107cf8..aa7f6f4f 100644 --- a/docs/Solana_Architecture/details/shine_users.md +++ b/docs/Solana_Architecture/details/shine_users.md @@ -135,3 +135,23 @@ - economy-настройки меняет DAO-authority; - upgrade-authority программы после проверки передается DAO; - пользовательские операции `create_user_pda` и `update_user_pda` остаются доступными обычным пользователям при корректных подписях и оплате. + +## ArchiveHeadBlock и серверный SHINE-ARCHIVE + +Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`): + +```text +archive_tx_id [32] +archive_hash [32] +``` + +Он хранит текущую голову архива конкретного SHiNE-аккаунта: raw Arweave TX ID и SHA-256 соответствующего большого `SHINE-ARCHIVE`. Подробный бинарный формат и серверный workflow находятся в `docs/Archive/01_PROTOCOL_v1.0.md`. + +Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим: + +- legacy payload без archive extension сохраняет старый block `100`; +- новый payload может заменить/очистить archive head; +- итоговая полная User PDA запись, включая block `100`, покрывается обычной root-подписью. + +Это позволяет обычным старым клиентским обновлениям профиля не стирать archive head серверного publisher-а. + diff --git a/settings.gradle b/settings.gradle index 008cebfb..518f00ae 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,6 +9,7 @@ include 'shine-server-db' include 'shine-server-net-protocol' include 'shine-server-net-server' include 'shine-server-solana-users-sync' +include 'shine-server-archive' project(':shine-server-log').projectDir = file('SHiNE-server/shine-server-log') project(':shine-server-config').projectDir = file('SHiNE-server/shine-server-config') @@ -19,3 +20,4 @@ project(':shine-server-db').projectDir = file('SHiNE-server/shine-server-db') project(':shine-server-net-protocol').projectDir = file('SHiNE-server/shine-server-net-protocol') project(':shine-server-net-server').projectDir = file('SHiNE-server/shine-server-net-server') project(':shine-server-solana-users-sync').projectDir = file('SHiNE-server/shine-server-solana-users-sync') +project(':shine-server-archive').projectDir = file('SHiNE-server/shine-server-archive') diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 3f4124f8..58e1c570 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -3157,22 +3157,6 @@ export class AuthService { return response.payload || {}; } - async getTestFreeAvatarQuota() { - const response = await this.ws.request('TestGetFreeAvatarQuota', {}); - if (response.status !== 200) throw opError('TestGetFreeAvatarQuota', response); - return response.payload || {}; - } - - async uploadTestFreeAvatar({ contentType, fileBytesBase64, sha256Hex }) { - const response = await this.ws.request('TestUploadFreeAvatar', { - contentType, - fileBytesBase64, - sha256Hex, - }, 60000); - if (response.status !== 200) throw opError('TestUploadFreeAvatar', response); - return response.payload || {}; - } - async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) { const cleanKind = String(kind || '').trim().toLowerCase(); const kinds = CONNECTION_SUBTYPES[cleanKind]; diff --git a/shine-UI/js/services/shine-user-pda-service.js b/shine-UI/js/services/shine-user-pda-service.js index 41e10ddf..b98cfc93 100644 --- a/shine-UI/js/services/shine-user-pda-service.js +++ b/shine-UI/js/services/shine-user-pda-service.js @@ -1,4 +1,4 @@ -import { base58ToBytes, base64ToBytes, bytesToBase58, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; +import { base58ToBytes, base64ToBytes, base64UrlToBytes, bytesToBase58, bytesToBase64Url, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; import { extractSeed32FromPkcs8B64 } from './client-key-utils.js'; import { SHINE_LOGIN_GUARD_PROGRAM_ID, @@ -27,6 +27,7 @@ const BLOCK_TYPE_SERVER_PROFILE = 30; const BLOCK_TYPE_ACCESS_SERVERS = 40; const BLOCK_TYPE_SESSIONS = 50; const BLOCK_TYPE_TRUSTED_STATE = 70; +const BLOCK_TYPE_ARCHIVE_HEAD = 100; const SESSIONS_MODE_MIXED = 1; const SESSION_TYPE_USER = 1; const SESSION_TYPE_HOMESERVER = 100; @@ -326,6 +327,8 @@ function createPdaState({ sessionsMode, sessions, trustedCount, + archiveHeadTxId = '', + archiveHeadHash = null, }) { const serverProfile = isServer ? { addressFormatType: Number(addressFormatType || 0), @@ -360,6 +363,8 @@ function createPdaState({ sessionPubKey32: x?.sessionPubKey32 instanceof Uint8Array ? x.sessionPubKey32 : new Uint8Array(x?.sessionPubKey32 || 32), })) : [], trustedCount: Number(trustedCount || 0) & 0xff, + archiveHeadTxId: String(archiveHeadTxId || '').trim(), + archiveHeadHash: archiveHeadHash instanceof Uint8Array ? archiveHeadHash : new Uint8Array(archiveHeadHash || 32), }; } @@ -445,6 +450,8 @@ export function parseShineUserPda(dataBytes) { let sessionsMode = SESSIONS_MODE_MIXED; let sessions = []; let trustedCount = 0; + let archiveHeadTxId = ''; + let archiveHeadHash = new Uint8Array(32); for (let i = 0; i < blocksCount; i += 1) { const blockType = reader.readU8(); @@ -528,6 +535,11 @@ export function parseShineUserPda(dataBytes) { trustedCount = reader.readU8(); continue; } + if (blockType === BLOCK_TYPE_ARCHIVE_HEAD) { + archiveHeadTxId = bytesToBase64Url(reader.readBytes(32)); + archiveHeadHash = reader.readBytes(32); + continue; + } throw new Error(`Неизвестный блок PDA: ${blockType}`); } @@ -556,6 +568,8 @@ export function parseShineUserPda(dataBytes) { sessionsMode, sessions, trustedCount, + archiveHeadTxId, + archiveHeadHash, }); return { @@ -586,6 +600,8 @@ export function serializeUnsignedRecordFromState(stateLike) { sessionsMode: stateLike.sessionsMode, sessions: stateLike.sessions, trustedCount: stateLike.trustedCount, + archiveHeadTxId: stateLike.archiveHeadTxId, + archiveHeadHash: stateLike.archiveHeadHash, }); const buf = [0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0]; @@ -594,7 +610,8 @@ export function serializeUnsignedRecordFromState(stateLike) { pushU32LE(buf, state.recordNumber); for (const x of state.prevRecordHash) buf.push(x); pushStrU8(buf, state.login); - buf.push(state.isServer ? 8 : 7); + const hasArchiveHead = Boolean(state.archiveHeadTxId); + buf.push((state.isServer ? 8 : 7) + (hasArchiveHead ? 1 : 0)); buf.push(BLOCK_TYPE_RECOVERY_KEY, 0); for (const x of state.recoveryKey) buf.push(x); @@ -636,6 +653,16 @@ export function serializeUnsignedRecordFromState(stateLike) { buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, state.trustedCount & 0xff); + if (hasArchiveHead) { + const txId32 = base64UrlToBytes(state.archiveHeadTxId); + if (txId32.length !== 32 || state.archiveHeadHash.length !== 32) { + throw new Error('Archive head должен содержать TX ID/hash по 32 байта'); + } + buf.push(BLOCK_TYPE_ARCHIVE_HEAD, 0); + for (const x of txId32) buf.push(x); + for (const x of state.archiveHeadHash) buf.push(x); + } + const recordLen = buf.length + 64; buf[7] = recordLen & 0xff; buf[8] = (recordLen >>> 8) & 0xff; @@ -1037,6 +1064,8 @@ export async function updateShineUserPdaOnSolana({ serverProfile, accessServers, trustedCount, + archiveHeadTxId = '', + archiveHeadHash = null, }) { const current = await readShineUserPda({ login, solanaEndpoint }); const cleanLogin = current.login; @@ -1134,6 +1163,8 @@ export async function updateShineUserPdaOnSolana({ sessionsMode: current.sessionsMode, sessions: current.sessions, trustedCount: trustedCount == null ? current.trustedCount : trustedCount, + archiveHeadTxId: current.archiveHeadTxId, + archiveHeadHash: current.archiveHeadHash, }); const unsignedNext = serializeUnsignedRecordFromState(nextState); diff --git a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md index 877a827f..ac2e64c0 100644 --- a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md +++ b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md @@ -94,16 +94,17 @@ UserPdaRecordV1 | `40` | `AccessServersBlock` | Серверы доступа/relay. | | `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. | | `70` | `TrustedStateBlock` | Счетчик trusted-связей. | +| `100` | `ArchiveHeadBlock` | Текущая голова серверного SHINE-ARCHIVE: Arweave TX ID + SHA-256 архива. | | `255` | `ReservedBlock` | Зарезервировано, пока не используется. | Правила: - неизвестный `block_type` в `format_major = 1` считается ошибкой; - обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`; -- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`; +- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`; - каждый обязательный блок должен встречаться ровно один раз; - порядок блоков в записи фиксируется для простоты проверки: - `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`. + `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`, `ArchiveHead`. ## 6. RecoveryKeyBlock @@ -359,6 +360,28 @@ TrustedStateBlock Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат. +## 15.1. ArchiveHeadBlock + +Необязательный блок текущей головы серверного архива. Он используется archive-capable сервером и хранится в том же User PDA. + +```text +ArchiveHeadBlock +- block_type: u8 = 100 +- block_version: u8 = 0 +- archive_tx_id: [u8; 32] +- archive_hash: [u8; 32] +``` + +Семантика: + +- `archive_tx_id` — raw 32-byte Arweave transaction id последнего опубликованного большого `SHINE-ARCHIVE`; текстовая Base64URL-форма получается вне PDA; +- `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 без изменений**; +- расширенный `update_user_pda` может заменить archive head или явно очистить его; отдельной Solana instruction для архива нет. + +`ArchiveHeadBlock` входит в unsigned bytes User PDA и тем самым покрывается обычной root-подписью записи. + ## 16. Подпись user_pda Подписывается не вся PDA целиком, а unsigned-часть записи: @@ -392,6 +415,7 @@ Solana-программа проверяет подпись через встр - обязательные блоки присутствуют; - создается минимум один `BlockchainRecord`; - новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`; +- `ArchiveHeadBlock` при регистрации не обязателен; обычный пользователь/сервер может начать публиковать архив позже; - стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит; - `used_bytes <= paid_limit_bytes`; - пользователь платит регистрационную комиссию; @@ -408,6 +432,7 @@ Solana-программа проверяет подпись через встр - `prev_record_hash` равен хэшу unsigned-части предыдущей записи; - `updated_at_ms` обновляется; - unsigned-часть новой записи подписана `root_key`; +- если archive extension в instruction отсутствует (legacy client), старый `ArchiveHeadBlock` сохраняется; если extension присутствует, применяется переданное `archive_head_update`; - лимиты блокчейнов могут только увеличиваться; - занятый размер и номер последнего блока не могут уменьшаться; - при увеличении оплаченного лимита пользователь доплачивает комиссию; diff --git a/shine-solana/shine/doc/programs/shine_users.md b/shine-solana/shine/doc/programs/shine_users.md index f65b07a9..aaea7bbd 100644 --- a/shine-solana/shine/doc/programs/shine_users.md +++ b/shine-solana/shine/doc/programs/shine_users.md @@ -753,3 +753,23 @@ signature = Ed25519(blockchain_private_key, message_hash) - Anchor discriminator'ы и Anchor-ABI инструкций; - старые seed'ы, которые конфликтовали с уже существующим Anchor-состоянием в devnet; - внутренние helper-функции старой реализации. + +## ArchiveHeadBlock и серверный SHINE-ARCHIVE + +Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`): + +```text +archive_tx_id [32] +archive_hash [32] +``` + +Он хранит текущую голову архива конкретного SHiNE-аккаунта: raw Arweave TX ID и SHA-256 соответствующего большого `SHINE-ARCHIVE`. Подробный бинарный формат и серверный workflow находятся в `docs/Archive/01_PROTOCOL_v1.0.md`. + +Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим: + +- legacy payload без archive extension сохраняет старый block `100`; +- новый payload может заменить/очистить archive head; +- итоговая полная User PDA запись, включая block `100`, покрывается обычной root-подписью. + +Это позволяет обычным старым клиентским обновлениям профиля не стирать archive head серверного publisher-а. + diff --git a/shine-solana/shine/programs/shine_users/src/lib.rs b/shine-solana/shine/programs/shine_users/src/lib.rs index 3e64daaa..9c469fe0 100644 --- a/shine-solana/shine/programs/shine_users/src/lib.rs +++ b/shine-solana/shine/programs/shine_users/src/lib.rs @@ -36,6 +36,7 @@ const BLOCK_TYPE_SERVER_PROFILE: u8 = 30; const BLOCK_TYPE_ACCESS_SERVERS: u8 = 40; const BLOCK_TYPE_SESSIONS: u8 = 50; const BLOCK_TYPE_TRUSTED_STATE: u8 = 70; +const BLOCK_TYPE_ARCHIVE_HEAD: u8 = 100; const BLOCK_VERSION_0: u8 = 0; const BLOCKCHAIN_TYPE_MAIN_USER: u8 = 1; const SESSIONS_MODE_MIXED: u8 = 1; @@ -163,6 +164,8 @@ pub struct UpdateUserPdaArgs { pub prev_hash: [u8; 32], pub additional_limit: u64, pub fields: UserMutableFields, + /// None = legacy instruction, archive head сохранить; Some(None) = очистить; Some(Some) = заменить. + pub archive_head_update: Option>, pub signature: [u8; 64], } @@ -210,6 +213,12 @@ pub struct BlockchainRecord { pub arweave_tx_id: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArchiveHeadRecord { + pub arweave_tx_id: [u8; 32], + pub archive_hash: [u8; 32], +} + #[derive(Clone, Debug)] pub struct UserRecord { pub created_at_ms: u64, @@ -230,6 +239,7 @@ pub struct UserRecord { pub sessions_mode: u8, pub sessions: Vec, pub trusted_count: u8, + pub archive_head: Option, pub signature: [u8; 64], } @@ -349,17 +359,34 @@ fn parse_create_args(r: &mut Reader<'_>) -> Result) -> Result { + let login = r.read_string_u8()?; + let recovery_key = r.read_pubkey()?; + let root_key = r.read_pubkey()?; + let created_at_ms = r.read_u64()?; + let updated_at_ms = r.read_u64()?; + let version = r.read_u32()?; + let prev_hash = r.read_fixed_32()?; + let additional_limit = r.read_u64()?; + let fields = parse_fields(r)?; + + // Backward compatibility: legacy update после trusted_count содержит ровно 64-byte root signature. + let archive_head_update = if r.remaining() > 64 { + let present = r.read_u8()?; + match present { + 0 => Some(None), + 1 => Some(Some(ArchiveHeadRecord { + arweave_tx_id: r.read_fixed_32()?, + archive_hash: r.read_fixed_32()?, + })), + _ => return Err(ProgramError::from(ShineUsersError::InvalidInstruction)), + } + } else { + None + }; + Ok(UpdateUserPdaArgs { - login: r.read_string_u8()?, - recovery_key: r.read_pubkey()?, - root_key: r.read_pubkey()?, - created_at_ms: r.read_u64()?, - updated_at_ms: r.read_u64()?, - version: r.read_u32()?, - prev_hash: r.read_fixed_32()?, - additional_limit: r.read_u64()?, - fields: parse_fields(r)?, - signature: r.read_fixed_64()?, + login, recovery_key, root_key, created_at_ms, updated_at_ms, version, prev_hash, + additional_limit, fields, archive_head_update, signature: r.read_fixed_64()?, }) } @@ -590,6 +617,7 @@ fn process_create_user_pda<'a>(program_id: &Pubkey, accounts: &'a [AccountInfo<' sessions_mode: args.fields.sessions_mode, sessions: args.fields.sessions, trusted_count: args.fields.trusted_count, + archive_head: None, signature: [0; 64], }; validate_blockchain_limits(&record.blockchain, 0, 0, true)?; @@ -713,6 +741,10 @@ fn build_update_record(old_record: &UserRecord, args: &UpdateUserPdaArgs, new_ba sessions_mode: args.fields.sessions_mode, sessions: args.fields.sessions.clone(), trusted_count: args.fields.trusted_count, + archive_head: match &args.archive_head_update { + None => old_record.archive_head.clone(), + Some(value) => value.clone(), + }, signature: [0; 64], }) } @@ -857,6 +889,7 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { let mut sessions_mode = SESSIONS_MODE_MIXED; let mut sessions = Vec::new(); let mut trusted_count = 0u8; + let mut archive_head: Option = None; for _ in 0..blocks_count { let block_type = read_u8_from(useful, &mut cursor)?; @@ -907,6 +940,13 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { BLOCK_TYPE_TRUSTED_STATE => { trusted_count = read_u8_from(useful, &mut cursor)?; } + BLOCK_TYPE_ARCHIVE_HEAD => { + require!(archive_head.is_none(), ShineUsersError::InvalidRecordData); + archive_head = Some(ArchiveHeadRecord { + arweave_tx_id: read_fixed_32_from(useful, &mut cursor)?, + archive_hash: read_fixed_32_from(useful, &mut cursor)?, + }); + } _ => return Err(ProgramError::from(ShineUsersError::InvalidRecordFormat)), } } @@ -933,6 +973,7 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { sessions_mode, sessions, trusted_count, + archive_head, signature, }) } @@ -986,7 +1027,7 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result, ProgramErro out.push(login_bytes.len() as u8); out.extend_from_slice(login_bytes); - let blocks_count = if record.is_server { 8 } else { 7 }; + let blocks_count = (if record.is_server { 8 } else { 7 }) + if record.archive_head.is_some() { 1 } else { 0 }; out.push(blocks_count); write_recovery_key_block(&mut out, record); write_root_key_block(&mut out, record); @@ -996,6 +1037,7 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result, ProgramErro write_access_servers_block(&mut out, record)?; write_sessions_block(&mut out, record)?; write_trusted_state_block(&mut out, record); + if let Some(archive_head) = &record.archive_head { write_archive_head_block(&mut out, archive_head); } let record_len = out.len().checked_add(64).ok_or(ProgramError::from(ShineUsersError::MathOverflow))?; require!(record_len <= u16::MAX as usize, ShineUsersError::RecordTooLarge); @@ -1045,6 +1087,7 @@ fn write_sessions_block(out: &mut Vec, record: &UserRecord) -> Result<(), Pr out.push(BLOCK_TYPE_SESSIONS); out.push(BLOCK_VERSION_0); out.push(record.sessions_mode); require!(record.sessions.len() <= MAX_SESSIONS, ShineUsersError::InvalidRecordData); out.push(record.sessions.len() as u8); for session in &record.sessions { out.push(session.session_type); out.push(session.session_version); write_len_prefixed_string(out, &session.session_name)?; out.extend_from_slice(session.session_pub_key.as_ref()); } Ok(()) } fn write_trusted_state_block(out: &mut Vec, record: &UserRecord) { out.push(BLOCK_TYPE_TRUSTED_STATE); out.push(BLOCK_VERSION_0); out.push(record.trusted_count); } +fn write_archive_head_block(out: &mut Vec, archive: &ArchiveHeadRecord) { out.push(BLOCK_TYPE_ARCHIVE_HEAD); out.push(BLOCK_VERSION_0); out.extend_from_slice(&archive.arweave_tx_id); out.extend_from_slice(&archive.archive_hash); } fn write_len_prefixed_string(out: &mut Vec, value: &str) -> Result<(), ProgramError> { let bytes = value.as_bytes(); require!(bytes.len() <= u8::MAX as usize, ShineUsersError::InvalidRecordData); out.push(bytes.len() as u8); out.extend_from_slice(bytes); Ok(()) } fn verify_record_signature_hash(instructions_sysvar: &AccountInfo, root_key: &Pubkey, signature: &[u8; 64], message_hash: &[u8]) -> Result<[u8; 64], ProgramError> {