SHA256
Compare commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
be696d116b |
@@ -0,0 +1,29 @@
|
||||
# SHiNE ANS-104 `test5590` patch
|
||||
|
||||
Этот архив содержит **только новые/изменённые файлы** плюс `DELETE_FILES.txt`.
|
||||
|
||||
## Что меняется
|
||||
|
||||
- Frame v1 без поддержки старого формата.
|
||||
- Один пользовательский block = готовый Ed25519 ANS-104 DataItem.
|
||||
- Test tag: `App=test5590`; channel tag: `c=<canonical slug>`.
|
||||
- SHiNE `prevHash` остаётся `SHA-256(FrameV1Bytes)` и не зависит от Arweave ID.
|
||||
- PostgreSQL — единственное локальное хранилище пользовательских blocks.
|
||||
- Старый custom SHINE-ARCHIVE и filesystem blockchain storage удаляются.
|
||||
- Каждые ~15 минут локальные pending DataItems собираются в standard ANS-104 bundle и идут через существующий native Arweave L1 transport.
|
||||
- Другие серверы находят `App=test5590`, импортируют и не публикуют повторно.
|
||||
- `ESP32/**` **не изменён**.
|
||||
|
||||
## Состав
|
||||
|
||||
- изменено/добавлено файлов: 46
|
||||
- удалить файлов: 41
|
||||
|
||||
Применение и проверки: `docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md`.
|
||||
|
||||
## Проверки при сборке patch
|
||||
|
||||
- `node --check` для изменённых production JS: OK.
|
||||
- `diff -qr` каталога `ESP32` относительно исходного архива: без изменений.
|
||||
- Active Java refs на удалённые SHINE-ARCHIVE/file-recovery classes: отсутствуют.
|
||||
- `./gradlew testClasses --offline --no-daemon`: не запущен до компиляции, потому что wrapper требует скачать Gradle 8.14 с `services.gradle.org`, а рабочая среда не имеет внешнего сетевого доступа. Полный Gradle test обязателен после применения patch в dev-среде.
|
||||
@@ -0,0 +1,103 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/** Minimal ANS-104 binary bundle writer/reader. */
|
||||
public final class Ans104Bundle {
|
||||
public record Entry(byte[] id32, byte[] rawDataItem) {}
|
||||
|
||||
private Ans104Bundle() {}
|
||||
|
||||
public static byte[] write(List<byte[]> rawItems) {
|
||||
if (rawItems == null || rawItems.isEmpty()) throw new IllegalArgumentException("ANS-104 bundle is empty");
|
||||
if (rawItems.size() > 1_000_000) throw new IllegalArgumentException("Too many ANS-104 items");
|
||||
|
||||
List<Ans104DataItem> parsed = new ArrayList<>(rawItems.size());
|
||||
long total = 32L + 64L * rawItems.size();
|
||||
for (byte[] raw : rawItems) {
|
||||
Ans104DataItem item = new Ans104DataItem(raw);
|
||||
if (!item.verifySignature()) throw new IllegalArgumentException("Invalid ANS-104 DataItem signature");
|
||||
parsed.add(item);
|
||||
total = Math.addExact(total, raw.length);
|
||||
}
|
||||
if (total > Integer.MAX_VALUE) throw new IllegalArgumentException("Bundle too large for in-memory publisher: " + total);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream((int) total);
|
||||
out.writeBytes(uint256LE(rawItems.size()));
|
||||
for (int i = 0; i < rawItems.size(); i++) {
|
||||
out.writeBytes(uint256LE(rawItems.get(i).length));
|
||||
out.writeBytes(parsed.get(i).id32());
|
||||
}
|
||||
for (byte[] raw : rawItems) out.writeBytes(raw);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
public static List<Entry> read(byte[] bundle, int maxItems) {
|
||||
if (bundle == null || bundle.length < 32) throw new IllegalArgumentException("ANS-104 bundle too short");
|
||||
long countLong = readUint256AsLong(bundle, 0);
|
||||
if (countLong <= 0 || countLong > Math.max(1, maxItems)) {
|
||||
throw new IllegalArgumentException("Bad ANS-104 bundle item count: " + countLong);
|
||||
}
|
||||
int count = (int) countLong;
|
||||
long headerLong = 32L + 64L * count;
|
||||
if (headerLong > bundle.length) throw new IllegalArgumentException("ANS-104 bundle header truncated");
|
||||
int header = (int) headerLong;
|
||||
long[] sizes = new long[count];
|
||||
byte[][] ids = new byte[count][];
|
||||
int hp = 32;
|
||||
long payloadBytes = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
sizes[i] = readUint256AsLong(bundle, hp); hp += 32;
|
||||
ids[i] = Arrays.copyOfRange(bundle, hp, hp + 32); hp += 32;
|
||||
if (sizes[i] <= 0 || sizes[i] > Integer.MAX_VALUE) throw new IllegalArgumentException("Bad DataItem size: " + sizes[i]);
|
||||
payloadBytes = Math.addExact(payloadBytes, sizes[i]);
|
||||
}
|
||||
if (headerLong + payloadBytes != bundle.length) {
|
||||
throw new IllegalArgumentException("ANS-104 bundle size mismatch: header=" + headerLong + " payload=" + payloadBytes + " actual=" + bundle.length);
|
||||
}
|
||||
List<Entry> out = new ArrayList<>(count);
|
||||
int p = header;
|
||||
for (int i = 0; i < count; i++) {
|
||||
int len = (int) sizes[i];
|
||||
byte[] raw = Arrays.copyOfRange(bundle, p, p + len);
|
||||
p += len;
|
||||
Ans104DataItem item = new Ans104DataItem(raw);
|
||||
if (!Arrays.equals(ids[i], item.id32())) throw new IllegalArgumentException("Bundle entry ID mismatch at index " + i);
|
||||
out.add(new Entry(ids[i], raw));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public static byte[] find(byte[] bundle, byte[] wantedId32, int maxItems) {
|
||||
if (wantedId32 == null || wantedId32.length != 32) throw new IllegalArgumentException("wanted ID must be 32 bytes");
|
||||
for (Entry e : read(bundle, maxItems)) {
|
||||
if (Arrays.equals(e.id32(), wantedId32)) return e.rawDataItem();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** ANS-104 bundle integers are 32-byte unsigned little-endian values. */
|
||||
static byte[] uint256LE(long value) {
|
||||
if (value < 0) throw new IllegalArgumentException("negative uint256");
|
||||
byte[] out = new byte[32];
|
||||
long v = value;
|
||||
for (int i = 0; i < 8; i++) { out[i] = (byte) (v & 0xff); v >>>= 8; }
|
||||
return out;
|
||||
}
|
||||
|
||||
static long readUint256AsLong(byte[] data, int offset) {
|
||||
if (offset < 0 || offset + 32 > data.length) throw new IllegalArgumentException("uint256 truncated");
|
||||
for (int i = offset + 8; i < offset + 32; i++) {
|
||||
if (data[i] != 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
|
||||
}
|
||||
long v = 0;
|
||||
for (int i = 7; i >= 0; i--) v = (v << 8) | (data[offset + i] & 0xffL);
|
||||
if (v < 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
|
||||
/** Единые локальные имена больших архивных файлов. */
|
||||
public final class ArchiveFileNames {
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yy");
|
||||
|
||||
private ArchiveFileNames() {}
|
||||
|
||||
/**
|
||||
* Временное имя полностью собранного файла до появления Arweave TX ID.
|
||||
* Пример: archive01.00001.11.09.26.tmp.SHiNE-archive
|
||||
*/
|
||||
public static String pendingFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
|
||||
return baseName(login, bigBlockNumber, createdAtMs, zone) + ".tmp.SHiNE-archive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Постоянное имя после успешной загрузки в Arweave.
|
||||
* Пример: archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive
|
||||
*/
|
||||
public static String uploadedFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
|
||||
if (txId == null || txId.length != 32) throw new IllegalArgumentException("Arweave TX ID должен быть 32 bytes");
|
||||
String tx = Base64.getUrlEncoder().withoutPadding().encodeToString(txId);
|
||||
return baseName(login, bigBlockNumber, createdAtMs, zone) + "." + tx + ".SHiNE-archive";
|
||||
}
|
||||
|
||||
public static Path pendingFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
|
||||
return archiveDir.resolve(pendingFileName(login, bigBlockNumber, createdAtMs, zone));
|
||||
}
|
||||
|
||||
public static Path uploadedFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
|
||||
return archiveDir.resolve(uploadedFileName(login, bigBlockNumber, createdAtMs, zone, txId));
|
||||
}
|
||||
|
||||
private static String baseName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
|
||||
if (bigBlockNumber < 0 || bigBlockNumber > 0xffff_ffffL) {
|
||||
throw new IllegalArgumentException("Номер большого блока вне u32: " + bigBlockNumber);
|
||||
}
|
||||
String safeLogin = sanitize(login);
|
||||
String number = String.format("%05d", bigBlockNumber);
|
||||
String date = DATE.format(Instant.ofEpochMilli(createdAtMs).atZone(zone == null ? ZoneId.systemDefault() : zone));
|
||||
return safeLogin + "." + number + "." + date;
|
||||
}
|
||||
|
||||
static String sanitize(String login) {
|
||||
String s = login == null ? "server" : login.trim();
|
||||
if (s.isEmpty()) s = "server";
|
||||
return s.replaceAll("[^A-Za-z0-9._-]", "_");
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/** Настройки чтения архивов других доверенных SHiNE-серверов. Пустой whitelist = импорт выключен. */
|
||||
public record ArchiveImportConfig(
|
||||
Set<String> allowedPublishers,
|
||||
int intervalMinutes,
|
||||
String arweaveGateway,
|
||||
Path workDir
|
||||
) {
|
||||
public static ArchiveImportConfig load() {
|
||||
AppConfig c = AppConfig.getInstance();
|
||||
Set<String> approved = new LinkedHashSet<>();
|
||||
String raw = c.getParam("archive.import.allowedPublishers");
|
||||
if (raw != null) {
|
||||
for (String item : raw.split(",")) {
|
||||
String login = item.trim().toLowerCase(Locale.ROOT);
|
||||
if (!login.isEmpty()) approved.add(login);
|
||||
}
|
||||
}
|
||||
String gateway = c.getParam("archive.arweave.gateway");
|
||||
if (gateway == null || gateway.isBlank()) gateway = "https://arweave.net";
|
||||
String dir = c.getParam("archive.import.workDir");
|
||||
if (dir == null || dir.isBlank()) dir = "data/archive-import";
|
||||
return new ArchiveImportConfig(
|
||||
Set.copyOf(approved),
|
||||
Math.max(1, c.getInt("archive.import.intervalMinutes", 60)),
|
||||
gateway.trim(),
|
||||
Path.of(dir));
|
||||
}
|
||||
|
||||
public boolean enabled() { return !allowedPublishers.isEmpty(); }
|
||||
}
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/**
|
||||
* Фоновая проверка уже синхронизированной локальной таблицы User PDA.
|
||||
* Solana отдельно здесь не опрашивается: первый запуск через 10 секунд после старта,
|
||||
* затем по умолчанию раз в час.
|
||||
*/
|
||||
public final class ArchiveImportScheduler {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchiveImportScheduler.class);
|
||||
private static ScheduledExecutorService executor;
|
||||
|
||||
private ArchiveImportScheduler() {}
|
||||
|
||||
public static synchronized void startOrLog() {
|
||||
if (executor != null) return;
|
||||
ArchiveImportConfig cfg = ArchiveImportConfig.load();
|
||||
if (!cfg.enabled()) {
|
||||
log.info("Archive importer выключен: archive.import.allowedPublishers пуст");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ArchiveImportService service = new ArchiveImportService(cfg);
|
||||
executor = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "archive-importer");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
executor.scheduleWithFixedDelay(
|
||||
() -> {
|
||||
try { service.pollOnce(); }
|
||||
catch (Exception e) { log.error("Archive importer cycle failed", e); }
|
||||
},
|
||||
10,
|
||||
Math.multiplyExact((long) cfg.intervalMinutes(), 60L),
|
||||
TimeUnit.SECONDS);
|
||||
log.info("Archive importer включён. approvedPublishers={} interval={}min",
|
||||
cfg.allowedPublishers(), cfg.intervalMinutes());
|
||||
} catch (Exception e) {
|
||||
log.error("Archive importer не запущен", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized void close() {
|
||||
if (executor != null) {
|
||||
executor.shutdownNow();
|
||||
executor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
-229
@@ -1,229 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.BchBlockEntry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||
import shine.db.archive.*;
|
||||
import shine.db.dao.*;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.*;
|
||||
import java.nio.file.*;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/** Импорт archive head только от серверов из server-side whitelist. */
|
||||
public final class ArchiveImportService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchiveImportService.class);
|
||||
private final ArchiveImportConfig cfg;
|
||||
private final ArchiveImportDAO dao = ArchiveImportDAO.getInstance();
|
||||
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
|
||||
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
|
||||
private final ShineArchiveReader reader = new ShineArchiveReader();
|
||||
private final Net_AddBlock_Handler addBlockHandler = new Net_AddBlock_Handler();
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
public ArchiveImportService(ArchiveImportConfig cfg) throws IOException {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
Files.createDirectories(cfg.workDir());
|
||||
}
|
||||
|
||||
/**
|
||||
* Никаких отдельных Solana RPC запросов здесь нет.
|
||||
* Читаем только solana_user_pda_current, которую уже обновляет обычный Solana users sync.
|
||||
*/
|
||||
public void pollOnce() throws Exception {
|
||||
if (!cfg.enabled()) return;
|
||||
for (ArchivePublisherHead head : dao.listPendingPublisherHeads(cfg.allowedPublishers())) {
|
||||
try {
|
||||
importHead(head);
|
||||
} catch (Exception e) {
|
||||
log.error("Archive import publisher={} tx={} failed", head.login(), head.archiveHeadTxId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void importHead(ArchivePublisherHead publisher) throws Exception {
|
||||
String headTxId = publisher.archiveHeadTxId();
|
||||
byte[] headHash = hex(publisher.archiveHeadHash());
|
||||
|
||||
// Если процесс успел импортировать head, но упал до установки archive_imported=true,
|
||||
// следующий цикл просто завершает отметку без повторного скачивания всей истории.
|
||||
if (headTxId.equals(publisher.lastImportedArchiveTxId())) {
|
||||
if (!dao.markHeadImported(publisher.login(), headTxId)) {
|
||||
log.info("Archive head changed while finalizing import marker publisher={} oldHead={}",
|
||||
publisher.login(), headTxId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Path headFile = download(headTxId);
|
||||
ShineArchiveReader.Document head;
|
||||
try {
|
||||
head = reader.readAndVerify(headFile, publisher.login(), headHash, publisher.rootKey());
|
||||
} finally {
|
||||
Files.deleteIfExists(headFile);
|
||||
}
|
||||
|
||||
// FULL reference table позволяет догнать все archive blocks, пропущенные пока сервер был выключен.
|
||||
List<ShineArchiveReader.Reference> previous = new ArrayList<>(head.references());
|
||||
previous.sort(Comparator.comparingLong(ShineArchiveReader.Reference::bigBlockNumber));
|
||||
|
||||
int startIndex = findResumeIndex(previous, publisher.lastImportedArchiveTxId(), headTxId);
|
||||
for (int i = startIndex; i < previous.size(); i++) {
|
||||
ShineArchiveReader.Reference ref = previous.get(i);
|
||||
importOne(publisher, ref.arweaveTxId(), ref.archiveHash());
|
||||
if (!dao.advanceImportCursor(publisher.login(), headTxId, ref.arweaveTxId())) {
|
||||
throw new IllegalStateException("Archive head changed during import for publisher " + publisher.login());
|
||||
}
|
||||
}
|
||||
|
||||
// Head уже скачан и проверен выше — второй раз его из Arweave не качаем.
|
||||
importDocument(publisher, headTxId, head);
|
||||
if (!dao.advanceImportCursor(publisher.login(), headTxId, headTxId)) {
|
||||
throw new IllegalStateException("Archive head changed during final import for publisher " + publisher.login());
|
||||
}
|
||||
if (!dao.markHeadImported(publisher.login(), headTxId)) {
|
||||
throw new IllegalStateException("Archive head changed before imported marker for publisher " + publisher.login());
|
||||
}
|
||||
|
||||
log.info("Archive head fully imported publisher={} bigBlock={} tx={}",
|
||||
publisher.login(), head.bigBlockNumber(), headTxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает индекс первого ещё не импортированного previous big block.
|
||||
* Пустой cursor = новый сервер, импортируем всю FULL историю.
|
||||
*/
|
||||
private static int findResumeIndex(List<ShineArchiveReader.Reference> previous,
|
||||
String lastImportedTxId,
|
||||
String currentHeadTxId) {
|
||||
String last = lastImportedTxId == null ? "" : lastImportedTxId.trim();
|
||||
if (last.isEmpty()) return 0;
|
||||
if (last.equals(currentHeadTxId)) return previous.size();
|
||||
for (int i = 0; i < previous.size(); i++) {
|
||||
if (last.equals(previous.get(i).arweaveTxId())) return i + 1;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Последний импортированный archive TX не найден в FULL history текущего head: " + last);
|
||||
}
|
||||
|
||||
private void importOne(ArchivePublisherHead publisher, String txId, byte[] expectedHash) throws Exception {
|
||||
Path file = download(txId);
|
||||
try {
|
||||
ShineArchiveReader.Document doc = reader.readAndVerify(
|
||||
file, publisher.login(), expectedHash, publisher.rootKey());
|
||||
importDocument(publisher, txId, doc);
|
||||
} finally {
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
}
|
||||
|
||||
private void importDocument(ArchivePublisherHead publisher, String txId,
|
||||
ShineArchiveReader.Document doc) throws Exception {
|
||||
for (ShineArchiveReader.Chunk chunk : doc.chunks()) {
|
||||
processChunk(publisher.login(), txId, doc, chunk);
|
||||
}
|
||||
log.info("Archive imported publisher={} bigBlock={} tx={} chunks={}",
|
||||
publisher.login(), doc.bigBlockNumber(), txId, doc.chunks().size());
|
||||
}
|
||||
|
||||
private void processChunk(String publisherLogin, String txId,
|
||||
ShineArchiveReader.Document doc,
|
||||
ShineArchiveReader.Chunk chunk) throws Exception {
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(chunk.blockchainName());
|
||||
lock.lock();
|
||||
try {
|
||||
ensureState(chunk.blockchainName());
|
||||
BlockchainStateEntry state = stateDao.getByBlockchainName(chunk.blockchainName());
|
||||
int localLast = state == null ? -1 : state.getLastBlockNumber();
|
||||
long archiveLast = -1;
|
||||
|
||||
for (byte[] raw : chunk.rawRecords()) {
|
||||
BchBlockEntry block = new BchBlockEntry(raw);
|
||||
archiveLast = Integer.toUnsignedLong(block.blockNumber);
|
||||
|
||||
if (block.blockNumber <= localLast) {
|
||||
byte[] localHash = blocksDao.getHashByNumber(chunk.blockchainName(), block.blockNumber);
|
||||
if (!Arrays.equals(localHash, block.getHash32())) {
|
||||
throw new IllegalStateException(
|
||||
"Archive conflict " + chunk.blockchainName() + "#" + block.blockNumber);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.blockNumber != localLast + 1) {
|
||||
throw new IllegalStateException(
|
||||
"Archive gap " + chunk.blockchainName() + ": local=" + localLast +
|
||||
" next=" + block.blockNumber);
|
||||
}
|
||||
|
||||
var result = addBlockHandler.addBlockFromArchive(chunk.blockchainName(), raw);
|
||||
if (!result.ok()) {
|
||||
throw new IllegalStateException(
|
||||
"Archive AddBlock rejected " + chunk.blockchainName() + "#" +
|
||||
block.blockNumber + ": " + result.reasonCode());
|
||||
}
|
||||
localLast = block.blockNumber;
|
||||
}
|
||||
|
||||
if (archiveLast >= 0) {
|
||||
dao.upsertLocation(new ArchiveBlockchainLocation(
|
||||
chunk.blockchainName(), publisherLogin, txId, doc.archiveHash(), doc.bigBlockNumber(),
|
||||
chunk.offset(), chunk.size(), archiveLast, System.currentTimeMillis()));
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureState(String blockchainName) throws Exception {
|
||||
if (stateDao.getByBlockchainName(blockchainName) != null) return;
|
||||
ArchiveImportDAO.BlockchainIdentity id = dao.getBlockchainIdentity(blockchainName);
|
||||
if (id == null) throw new IllegalStateException("Нет User PDA для blockchain " + blockchainName);
|
||||
|
||||
BlockchainStateEntry s = new BlockchainStateEntry();
|
||||
s.setBlockchainName(id.blockchainName());
|
||||
s.setLogin(id.login());
|
||||
s.setBlockchainKey(id.blockchainKey());
|
||||
s.setSizeLimit(id.sizeLimit());
|
||||
s.setFileSizeBytes(0);
|
||||
s.setLastBlockNumber(-1);
|
||||
s.setLastBlockHash(null);
|
||||
s.setUpdatedAtMs(System.currentTimeMillis());
|
||||
stateDao.insertIfMissing(s);
|
||||
}
|
||||
|
||||
private Path download(String txId) throws Exception {
|
||||
String gateway = cfg.arweaveGateway().replaceAll("/+$", "");
|
||||
Path target = Files.createTempFile(cfg.workDir(), "archive-", ".download");
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/" + txId))
|
||||
.timeout(Duration.ofMinutes(10))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<Path> resp = http.send(req, HttpResponse.BodyHandlers.ofFile(target));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
Files.deleteIfExists(target);
|
||||
throw new IOException("Arweave HTTP " + resp.statusCode() + " tx=" + txId);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static byte[] hex(String value) {
|
||||
String s = String.valueOf(value == null ? "" : value).trim();
|
||||
if (s.length() != 64) throw new IllegalArgumentException("archive_head_hash должен быть HEX64");
|
||||
byte[] out = new byte[32];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import sync.util.Base58Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
|
||||
/** Читает Ed25519 seed32 из распространённых SHiNE/Solana форматов ключей. */
|
||||
final class ArchiveKeyLoader {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private ArchiveKeyLoader() {}
|
||||
|
||||
static byte[] loadSeed32(Path path) throws IOException {
|
||||
if (path == null) throw new IllegalArgumentException("Путь ключа не задан");
|
||||
byte[] raw = Files.readAllBytes(path);
|
||||
if (raw.length == 32) return raw.clone();
|
||||
if (raw.length == 64) return first32(raw);
|
||||
|
||||
String text = new String(raw, StandardCharsets.UTF_8).trim();
|
||||
if (text.isEmpty()) throw new IOException("Пустой key file: " + path);
|
||||
|
||||
// Solana keypair JSON: [64 чисел].
|
||||
if (text.startsWith("[")) {
|
||||
JsonNode arr = JSON.readTree(text);
|
||||
if (!arr.isArray() || (arr.size() != 32 && arr.size() != 64)) {
|
||||
throw new IOException("Ожидался JSON keypair на 32/64 байта: " + path);
|
||||
}
|
||||
byte[] bytes = new byte[arr.size()];
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
int v = arr.get(i).asInt(-1);
|
||||
if (v < 0 || v > 255) throw new IOException("Некорректный байт keypair: " + path);
|
||||
bytes[i] = (byte) v;
|
||||
}
|
||||
return bytes.length == 32 ? bytes : first32(bytes);
|
||||
}
|
||||
|
||||
// Base58 seed32 или Solana secret key 64 bytes.
|
||||
try {
|
||||
byte[] decoded = Base58Util.decode(text);
|
||||
if (decoded.length == 32) return decoded;
|
||||
if (decoded.length == 64) return first32(decoded);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Пробуем остальные текстовые форматы ниже.
|
||||
}
|
||||
|
||||
// Base64 raw seed / keypair / Ed25519 PKCS8. В SHiNE PKCS8 seed находится в последних 32 байтах.
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(text.replaceAll("\\s+", ""));
|
||||
if (decoded.length == 32) return decoded;
|
||||
if (decoded.length == 64) return first32(decoded);
|
||||
if (decoded.length > 32) {
|
||||
byte[] seed = new byte[32];
|
||||
System.arraycopy(decoded, decoded.length - 32, seed, 0, 32);
|
||||
return seed;
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Ниже выдадим понятную общую ошибку.
|
||||
}
|
||||
|
||||
throw new IOException("Не удалось распознать Ed25519 key file: " + path
|
||||
+ ". Поддерживаются raw seed32, raw/keypair64, Solana JSON, Base58 seed/keypair и Base64 PKCS8.");
|
||||
}
|
||||
|
||||
private static byte[] first32(byte[] bytes) {
|
||||
byte[] out = new byte[32];
|
||||
System.arraycopy(bytes, 0, out, 0, 32);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
import utils.config.SolanaProgramsConfig;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/** Настройки опционального архивного publisher-а. По умолчанию функция выключена. */
|
||||
public record ArchivePublisherConfig(
|
||||
boolean enabled,
|
||||
LocalTime publishTime,
|
||||
ZoneId publishZone,
|
||||
long maxFileBytes,
|
||||
Path workDir,
|
||||
String serverLogin,
|
||||
String arweaveGateway,
|
||||
Path arweaveWalletJwkPath,
|
||||
int arweaveMinConfirmations,
|
||||
int arweaveConfirmPollSeconds,
|
||||
int arweaveConfirmTimeoutMinutes,
|
||||
Path solanaRootKeyPath,
|
||||
Path solanaClientKeyPath,
|
||||
String solanaRpcUrl,
|
||||
String solanaUsersProgramId,
|
||||
String solanaPaymentsProgramId,
|
||||
int solanaConfirmPollSeconds,
|
||||
int solanaConfirmTimeoutMinutes,
|
||||
String solanaCommitment
|
||||
) {
|
||||
public static ArchivePublisherConfig load() {
|
||||
AppConfig c = AppConfig.getInstance();
|
||||
return new ArchivePublisherConfig(
|
||||
c.getBoolean("archive.publish.enabled", false),
|
||||
parseTime(orDefault(c.getParam("archive.publish.time"), "00:00")),
|
||||
parseZone(c.getParam("archive.publish.zoneId")),
|
||||
positiveLong(parseLong(c.getParam("archive.maxFileBytes"), 4_000_000_000L), "archive.maxFileBytes"),
|
||||
Path.of(orDefault(c.getParam("archive.workDir"), "data/archive")),
|
||||
requiredIfEnabled(c, "server.SHiNE.login"),
|
||||
orDefault(c.getParam("archive.arweave.gateway"), "https://arweave.net"),
|
||||
optionalPath(c.getParam("archive.arweave.walletJwkPath")),
|
||||
nonNegative(c.getInt("archive.arweave.minConfirmations", 1), "archive.arweave.minConfirmations"),
|
||||
positive(c.getInt("archive.arweave.confirmPollSeconds", 30), "archive.arweave.confirmPollSeconds"),
|
||||
positive(c.getInt("archive.arweave.confirmTimeoutMinutes", 180), "archive.arweave.confirmTimeoutMinutes"),
|
||||
optionalPath(c.getParam("archive.solana.rootKeyPath")),
|
||||
optionalPath(c.getParam("archive.solana.clientKeyPath")),
|
||||
firstNonBlank(c.getParam("solana.users.sync.rpcUrl"), c.getParam("solana.rpcUrl"), SolanaProgramsConfig.SOLANA_RPC_URL),
|
||||
firstNonBlank(c.getParam("solana.users.sync.programId"), SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID),
|
||||
SolanaProgramsConfig.SHINE_PAYMENTS_PROGRAM_ID,
|
||||
positive(c.getInt("archive.solana.confirmPollSeconds", 5), "archive.solana.confirmPollSeconds"),
|
||||
positive(c.getInt("archive.solana.confirmTimeoutMinutes", 30), "archive.solana.confirmTimeoutMinutes"),
|
||||
orDefault(c.getParam("archive.solana.commitment"), "finalized")
|
||||
);
|
||||
}
|
||||
|
||||
public void validateForStart() {
|
||||
if (!enabled) return;
|
||||
if (serverLogin == null || serverLogin.isBlank()) throw new IllegalArgumentException("server.SHiNE.login обязателен для archive publisher");
|
||||
if (arweaveWalletJwkPath == null) throw new IllegalArgumentException("archive.arweave.walletJwkPath обязателен");
|
||||
if (solanaRootKeyPath == null) throw new IllegalArgumentException("archive.solana.rootKeyPath обязателен");
|
||||
if (solanaClientKeyPath == null) throw new IllegalArgumentException("archive.solana.clientKeyPath обязателен");
|
||||
if (solanaRpcUrl == null || solanaRpcUrl.isBlank()) throw new IllegalArgumentException("solana.users.sync.rpcUrl или solana.rpcUrl обязателен");
|
||||
if (maxFileBytes >= 0x1_0000_0000L) {
|
||||
throw new IllegalArgumentException("SHINE-ARCHIVE v1 требует archive.maxFileBytes < 4294967296");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requiredIfEnabled(AppConfig c, String name) {
|
||||
String v = c.getParam(name);
|
||||
return v == null ? "" : v.trim();
|
||||
}
|
||||
private static Path optionalPath(String s) { return s == null || s.isBlank() ? null : Path.of(s.trim()); }
|
||||
private static String firstNonBlank(String... values) {
|
||||
if (values != null) for (String v : values) if (v != null && !v.isBlank()) return v.trim();
|
||||
return "";
|
||||
}
|
||||
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
|
||||
private static long parseLong(String s, long d) { return s == null || s.isBlank() ? d : Long.parseLong(s.trim()); }
|
||||
private static LocalTime parseTime(String value) {
|
||||
try {
|
||||
return LocalTime.parse(value.trim());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("archive.publish.time должен быть в формате HH:mm, например 00:00", e);
|
||||
}
|
||||
}
|
||||
private static ZoneId parseZone(String value) {
|
||||
if (value == null || value.isBlank()) return ZoneId.systemDefault();
|
||||
try {
|
||||
return ZoneId.of(value.trim());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Некорректный archive.publish.zoneId: " + value, e);
|
||||
}
|
||||
}
|
||||
private static int positive(int v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
|
||||
private static int nonNegative(int v, String n) { if (v < 0) throw new IllegalArgumentException(n + " должен быть >= 0"); return v; }
|
||||
private static long positiveLong(long v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
|
||||
}
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Опциональный scheduler: один новый archive job в заданное локальное время каждый день. */
|
||||
public final class ArchivePublisherScheduler {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherScheduler.class);
|
||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||
private static ScheduledExecutorService executor;
|
||||
|
||||
private ArchivePublisherScheduler() {}
|
||||
|
||||
public static void startOrLog() {
|
||||
ArchivePublisherConfig cfg;
|
||||
try {
|
||||
cfg = ArchivePublisherConfig.load();
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось прочитать archive publisher config", e);
|
||||
return;
|
||||
}
|
||||
if (!cfg.enabled()) {
|
||||
log.info("Archive publisher выключен (archive.publish.enabled=false)");
|
||||
return;
|
||||
}
|
||||
if (!STARTED.compareAndSet(false, true)) return;
|
||||
|
||||
try {
|
||||
ArchivePublisherService service = new ArchivePublisherService(cfg);
|
||||
executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
|
||||
@Override public Thread newThread(Runnable r) {
|
||||
Thread t = new Thread(r, "shine-archive-publisher");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}
|
||||
});
|
||||
|
||||
// Незавершённую публикацию после рестарта продолжаем сразу, а новый snapshot
|
||||
// создаём только в очередное назначенное суточное время.
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
service.resumeUnfinishedJob();
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось продолжить незавершённый archive job", e);
|
||||
}
|
||||
});
|
||||
scheduleNextDaily(service, cfg);
|
||||
log.info("Archive publisher включён: login={} dir={} dailyAt={} zone={}",
|
||||
cfg.serverLogin(), cfg.workDir(), cfg.publishTime(), cfg.publishZone());
|
||||
} catch (Exception e) {
|
||||
STARTED.set(false);
|
||||
log.error("Archive publisher НЕ запущен из-за ошибки preflight", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void scheduleNextDaily(ArchivePublisherService service, ArchivePublisherConfig cfg) {
|
||||
ScheduledExecutorService e = executor;
|
||||
if (e == null || e.isShutdown()) return;
|
||||
|
||||
ZonedDateTime now = ZonedDateTime.now(cfg.publishZone());
|
||||
ZonedDateTime next = now.toLocalDate().atTime(cfg.publishTime()).atZone(cfg.publishZone());
|
||||
if (!next.isAfter(now)) next = next.plusDays(1);
|
||||
long delayMs = Math.max(1L, Duration.between(now, next).toMillis());
|
||||
|
||||
e.schedule(() -> {
|
||||
try {
|
||||
service.runCycle();
|
||||
} catch (Exception ex) {
|
||||
log.error("Archive publisher daily cycle failed; retry нового snapshot будет в следующее назначенное время", ex);
|
||||
} finally {
|
||||
scheduleNextDaily(service, cfg);
|
||||
}
|
||||
}, delayMs, TimeUnit.MILLISECONDS);
|
||||
|
||||
log.info("Следующая архивная публикация запланирована на {}", next);
|
||||
}
|
||||
|
||||
public static void close() {
|
||||
ScheduledExecutorService e = executor;
|
||||
if (e != null) e.shutdownNow();
|
||||
}
|
||||
}
|
||||
-341
@@ -1,341 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchiveChainCursor;
|
||||
import shine.db.archive.ArchivePublishJob;
|
||||
import shine.db.archive.ArchivePublishJobChain;
|
||||
import shine.db.dao.ArchivePublicationDAO;
|
||||
import shine.db.dao.ArchiveImportDAO;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/** Один crash-safe цикл публикации большого SHINE-ARCHIVE блока. */
|
||||
public final class ArchivePublisherService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherService.class);
|
||||
|
||||
private final ArchivePublisherConfig cfg;
|
||||
private final ArchivePublicationDAO archiveDao = ArchivePublicationDAO.getInstance();
|
||||
private final ArchiveImportDAO archiveImportDao = ArchiveImportDAO.getInstance();
|
||||
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
|
||||
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
|
||||
private final ShineArchiveWriter writer = new ShineArchiveWriter();
|
||||
private final ArweaveArchiveService arweave;
|
||||
private final SolanaArchiveHeadWriter solana;
|
||||
private final byte[] archiveSigningSeed32;
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
public ArchivePublisherService(ArchivePublisherConfig cfg) throws Exception {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
cfg.validateForStart();
|
||||
Files.createDirectories(cfg.workDir());
|
||||
this.archiveSigningSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
|
||||
this.arweave = new ArweaveArchiveService(cfg);
|
||||
this.solana = new SolanaArchiveHeadWriter(cfg);
|
||||
this.solana.validateKeysAgainstPda();
|
||||
backfillOwnLocationIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* После обновления старого publisher-сервера заполняет универсальный location index
|
||||
* из уже существующих finalized cursors, не дожидаясь следующего суточного archive block.
|
||||
*/
|
||||
private void backfillOwnLocationIndex() throws Exception {
|
||||
Map<Long, ArchiveBigBlockRef> finalized = new HashMap<>();
|
||||
for (ArchiveBigBlockRef ref : archiveDao.listFinalizedBigBlocks()) finalized.put(ref.bigBlockNumber(), ref);
|
||||
if (finalized.isEmpty()) return;
|
||||
long now = System.currentTimeMillis();
|
||||
int updated = 0;
|
||||
for (BlockchainStateEntry state : stateDao.listAll()) {
|
||||
if (state == null || state.getBlockchainName() == null || state.getBlockchainName().isBlank()) continue;
|
||||
ArchiveChainCursor cursor = archiveDao.getCursor(state.getBlockchainName());
|
||||
if (cursor == null) continue;
|
||||
ArchiveBigBlockRef ref = finalized.get(cursor.lastArchiveBigBlockNumber());
|
||||
if (ref == null || ref.arweaveTxId() == null || ref.arweaveTxId().length != 32) continue;
|
||||
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
|
||||
cursor.blockchainName(), cfg.serverLogin(), b64url(ref.arweaveTxId()),
|
||||
cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(),
|
||||
cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), now));
|
||||
updated++;
|
||||
}
|
||||
if (updated > 0) log.info("Archive publisher: восстановлен location index для {} blockchain", updated);
|
||||
}
|
||||
|
||||
/** После рестарта продолжает только уже существующий незавершённый job; новый snapshot не создаёт. */
|
||||
public boolean resumeUnfinishedJob() throws Exception {
|
||||
if (!running.compareAndSet(false, true)) return false;
|
||||
try {
|
||||
ArchivePublishJob job = archiveDao.findUnfinishedJob();
|
||||
if (job == null) return false;
|
||||
log.info("Archive publisher: после рестарта продолжаем job id={} block={} status={}",
|
||||
job.id(), job.bigBlockNumber(), job.status());
|
||||
process(job.id());
|
||||
return true;
|
||||
} finally {
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Запускает суточный цикл: продолжает незавершённый job либо создаёт новый frozen snapshot. */
|
||||
public boolean runCycle() throws Exception {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
log.info("Archive publisher: предыдущий цикл ещё работает, новый пропущен");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
ArchivePublishJob job = archiveDao.findUnfinishedJob();
|
||||
if (job == null) {
|
||||
job = createFrozenJob();
|
||||
if (job == null) {
|
||||
log.info("Archive publisher: новых SHiNE-блоков нет");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
log.info("Archive publisher: продолжаем незавершённый job id={} block={} status={}",
|
||||
job.id(), job.bigBlockNumber(), job.status());
|
||||
}
|
||||
process(job.id());
|
||||
return true;
|
||||
} finally {
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private ArchivePublishJob createFrozenJob() throws Exception {
|
||||
List<ArchivePublishJobChain> chains = new ArrayList<>();
|
||||
for (BlockchainStateEntry listed : stateDao.listAll()) {
|
||||
if (listed == null || listed.getBlockchainName() == null || listed.getBlockchainName().isBlank()) continue;
|
||||
String name = listed.getBlockchainName();
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(name);
|
||||
lock.lock();
|
||||
try {
|
||||
BlockchainStateEntry state = stateDao.getByBlockchainName(name);
|
||||
if (state == null || state.getLastBlockNumber() < 0) continue;
|
||||
ArchiveChainCursor cursor = archiveDao.getCursor(name);
|
||||
long from;
|
||||
byte[] previousSourceHash;
|
||||
Long prevBig = null;
|
||||
byte[] prevBigHash = null;
|
||||
Long prevChunkOffset = null;
|
||||
Long prevChunkSize = null;
|
||||
|
||||
if (cursor == null) {
|
||||
from = 0L;
|
||||
previousSourceHash = new byte[32];
|
||||
} else {
|
||||
long cursorBlock = cursor.lastArchivedSourceBlockNumber();
|
||||
if (cursorBlock < 0 || cursorBlock > 0xffff_ffffL) {
|
||||
throw new IllegalStateException("Некорректный archive cursor для " + name + ": " + cursorBlock);
|
||||
}
|
||||
byte[] actualCursorHash = blocksDao.getHashByNumber(name, (int) cursorBlock);
|
||||
if (!Arrays.equals(actualCursorHash, cursor.lastArchivedSourceBlockHash())) {
|
||||
throw new IllegalStateException("Archive cursor hash не совпадает с локальной blockchain: " + name + "#" + cursorBlock);
|
||||
}
|
||||
from = cursorBlock + 1L;
|
||||
previousSourceHash = cursor.lastArchivedSourceBlockHash();
|
||||
prevBig = cursor.lastArchiveBigBlockNumber();
|
||||
prevBigHash = cursor.lastArchiveBigBlockHash();
|
||||
prevChunkOffset = cursor.lastChunkOffset();
|
||||
prevChunkSize = cursor.lastChunkSize();
|
||||
}
|
||||
|
||||
long to = Integer.toUnsignedLong(state.getLastBlockNumber());
|
||||
if (from > to) continue;
|
||||
if (from > 0xffff_ffffL || to > 0xffff_ffffL) throw new IllegalStateException("Source block number вне u32: " + name);
|
||||
byte[] lastHash = state.getLastBlockHash();
|
||||
if (lastHash == null || lastHash.length != 32) {
|
||||
lastHash = blocksDao.getHashByNumber(name, (int) to);
|
||||
}
|
||||
require32(lastHash, "last source block hash " + name);
|
||||
chains.add(new ArchivePublishJobChain(
|
||||
0L, name, from, to, previousSourceHash, lastHash,
|
||||
prevBig, prevBigHash, prevChunkOffset, prevChunkSize, null, null));
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
if (chains.isEmpty()) return null;
|
||||
chains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
|
||||
long bigBlock = archiveDao.nextBigBlockNumber();
|
||||
if (bigBlock > 0xffff_ffffL) throw new IllegalStateException("Закончился диапазон u32 big_block_number");
|
||||
long createdAt = System.currentTimeMillis();
|
||||
long id = archiveDao.createJobWithChains(bigBlock, createdAt, chains);
|
||||
log.info("Archive snapshot создан: job={} bigBlock={} chains={}", id, bigBlock, chains.size());
|
||||
return archiveDao.getJob(id);
|
||||
}
|
||||
|
||||
private void process(long jobId) throws Exception {
|
||||
while (true) {
|
||||
ArchivePublishJob job = archiveDao.getJob(jobId);
|
||||
if (job == null) throw new IllegalStateException("Archive job исчез: " + jobId);
|
||||
switch (job.status()) {
|
||||
case "SNAPSHOT_CREATED" -> buildFile(job);
|
||||
case "FILE_BUILT" -> uploadArweave(job);
|
||||
case "ARWEAVE_UPLOADED" -> waitArweave(job);
|
||||
case "ARWEAVE_CONFIRMED" -> submitSolana(job);
|
||||
case "SOLANA_SUBMITTED" -> waitSolana(job);
|
||||
case "SOLANA_FINALIZED" -> commitCursors(job);
|
||||
case "CURSORS_COMMITTED" -> {
|
||||
log.info("Archive job завершён: id={} bigBlock={} file={} tx={}", job.id(), job.bigBlockNumber(), job.localArchivePath(), b64url(job.arweaveTxId()));
|
||||
return;
|
||||
}
|
||||
case "FAILED" -> throw new IllegalStateException("Archive job помечен FAILED: " + job.errorText());
|
||||
default -> throw new IllegalStateException("Неизвестный archive job status: " + job.status());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void buildFile(ArchivePublishJob job) throws Exception {
|
||||
List<ArchivePublishJobChain> chains = archiveDao.listJobChains(job.id());
|
||||
if (chains.isEmpty()) throw new IllegalStateException("SNAPSHOT_CREATED job не содержит chains: " + job.id());
|
||||
Map<String,List<BlockEntry>> records = new LinkedHashMap<>();
|
||||
|
||||
for (ArchivePublishJobChain ch : chains) {
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(ch.blockchainName());
|
||||
lock.lock();
|
||||
try {
|
||||
if (ch.fromSourceBlockNumber() > 0) {
|
||||
byte[] prev = blocksDao.getHashByNumber(ch.blockchainName(), (int)(ch.fromSourceBlockNumber() - 1));
|
||||
if (!Arrays.equals(prev, ch.previousSourceBlockHash())) {
|
||||
throw new IllegalStateException("Frozen previous source hash изменился: " + ch.blockchainName());
|
||||
}
|
||||
}
|
||||
List<BlockEntry> range = blocksDao.listRangeByNumber(
|
||||
ch.blockchainName(), (int)ch.fromSourceBlockNumber(), (int)ch.toSourceBlockNumber());
|
||||
if (range.isEmpty()) throw new IllegalStateException("Frozen range пуст: " + ch.blockchainName());
|
||||
byte[] last = range.get(range.size()-1).getBlockHash();
|
||||
if (!Arrays.equals(last, ch.lastSourceBlockHash())) {
|
||||
throw new IllegalStateException("Frozen last source hash изменился: " + ch.blockchainName());
|
||||
}
|
||||
records.put(ch.blockchainName(), range);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
List<ArchiveBigBlockRef> previous = archiveDao.listFinalizedBigBlocks();
|
||||
Path file = ArchiveFileNames.pendingFilePath(
|
||||
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone());
|
||||
ShineArchiveWriter.BuildResult result = writer.build(
|
||||
file, cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), previous,
|
||||
chains, records, archiveSigningSeed32, cfg.maxFileBytes());
|
||||
for (Map.Entry<String, ShineArchiveWriter.ChunkPosition> e : result.chunks().entrySet()) {
|
||||
archiveDao.updateNewChunkPosition(job.id(), e.getKey(), e.getValue().offset(), e.getValue().size());
|
||||
}
|
||||
archiveDao.markFileBuilt(job.id(), result.file().toString(), result.archiveHash());
|
||||
log.info("SHINE-ARCHIVE локально сохранён: block={} file={} bytes={} hash={}",
|
||||
job.bigBlockNumber(), result.file(), result.fileSize(), hex(result.archiveHash()));
|
||||
}
|
||||
|
||||
private void uploadArweave(ArchivePublishJob job) throws Exception {
|
||||
Path file = requireLocalFile(job);
|
||||
byte[] verified = ShineArchiveWriter.verifyLocalFile(file);
|
||||
if (!Arrays.equals(verified, job.archiveHash())) throw new IllegalStateException("Локальный archive hash отличается от job DB");
|
||||
ArweaveArchiveService.UploadResult upload = arweave.upload(file, cfg.serverLogin(), job.bigBlockNumber(), job.archiveHash());
|
||||
// Сначала надёжно фиксируем полученный TX ID в БД. Если процесс упадёт до rename,
|
||||
// recovery на следующем шаге переименует тот же временный файл без повторной загрузки.
|
||||
archiveDao.markArweaveUploaded(job.id(), upload.txIdBytes());
|
||||
Path uploadedFile = markLocalFileUploaded(job, file, upload.txIdBytes());
|
||||
log.info("SHINE-ARCHIVE загружен в Arweave: block={} tx={} file={}",
|
||||
job.bigBlockNumber(), upload.txId(), uploadedFile);
|
||||
}
|
||||
|
||||
private void waitArweave(ArchivePublishJob job) throws Exception {
|
||||
ensureUploadedLocalName(job);
|
||||
String tx = b64url(job.arweaveTxId());
|
||||
int confirmations = arweave.waitForConfirmations(tx);
|
||||
archiveDao.markArweaveConfirmed(job.id(), confirmations);
|
||||
log.info("Arweave archive закреплён: block={} tx={} confirmations={}", job.bigBlockNumber(), tx, confirmations);
|
||||
}
|
||||
|
||||
|
||||
/** После успешного Arweave upload временный файл получает реальный TX ID в имени и остаётся локально. */
|
||||
private Path markLocalFileUploaded(ArchivePublishJob job, Path currentFile, byte[] txId) throws Exception {
|
||||
require32(txId, "arweave tx id");
|
||||
Path target = ArchiveFileNames.uploadedFilePath(
|
||||
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), txId);
|
||||
Files.createDirectories(target.toAbsolutePath().getParent());
|
||||
if (!currentFile.equals(target)) {
|
||||
try {
|
||||
Files.move(currentFile, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(currentFile, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
archiveDao.updateLocalArchivePath(job.id(), target.toString());
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Crash recovery: TX уже сохранён, а временный файл ещё не успели переименовать. */
|
||||
private void ensureUploadedLocalName(ArchivePublishJob job) throws Exception {
|
||||
require32(job.arweaveTxId(), "job.arweave_tx_id");
|
||||
Path target = ArchiveFileNames.uploadedFilePath(
|
||||
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), job.arweaveTxId());
|
||||
if (Files.isRegularFile(target)) {
|
||||
if (!target.toString().equals(job.localArchivePath())) {
|
||||
archiveDao.updateLocalArchivePath(job.id(), target.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) return;
|
||||
Path current = Path.of(job.localArchivePath());
|
||||
if (Files.isRegularFile(current)) {
|
||||
markLocalFileUploaded(job, current, job.arweaveTxId());
|
||||
}
|
||||
}
|
||||
|
||||
private void submitSolana(ArchivePublishJob job) throws Exception {
|
||||
require32(job.arweaveTxId(), "job.arweave_tx_id");
|
||||
require32(job.archiveHash(), "job.archive_hash");
|
||||
SolanaArchiveHeadWriter.SubmitResult submitted = solana.submitArchiveHead(job.arweaveTxId(), job.archiveHash());
|
||||
archiveDao.markSolanaSubmitted(job.id(), submitted.signature());
|
||||
}
|
||||
|
||||
private void waitSolana(ArchivePublishJob job) throws Exception {
|
||||
if (job.solanaSignature() == null || job.solanaSignature().isBlank()) throw new IllegalStateException("SOLANA_SUBMITTED без signature");
|
||||
solana.waitFinalized(job.solanaSignature());
|
||||
if (!solana.currentHeadMatches(job.arweaveTxId(), job.archiveHash())) {
|
||||
throw new IllegalStateException("После Solana finalized User PDA archive head не совпадает с job");
|
||||
}
|
||||
archiveDao.markSolanaFinalized(job.id());
|
||||
log.info("Solana archive head finalized: block={} signature={}", job.bigBlockNumber(), job.solanaSignature());
|
||||
}
|
||||
|
||||
private void commitCursors(ArchivePublishJob job) throws Exception {
|
||||
archiveDao.commitCursors(job.id(), job.archiveHash());
|
||||
|
||||
// Тот же универсальный индекс используется и для собственных опубликованных архивов.
|
||||
// Поэтому UI publisher-сервера получает ссылку даже если он не импортирует самого себя через whitelist.
|
||||
String txId = b64url(job.arweaveTxId());
|
||||
long now = System.currentTimeMillis();
|
||||
for (ArchivePublishJobChain ch : archiveDao.listJobChains(job.id())) {
|
||||
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) continue;
|
||||
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
|
||||
ch.blockchainName(), cfg.serverLogin(), txId, job.archiveHash(), job.bigBlockNumber(),
|
||||
ch.newChunkOffset(), ch.newChunkSize(), ch.toSourceBlockNumber(), now));
|
||||
}
|
||||
}
|
||||
|
||||
private static Path requireLocalFile(ArchivePublishJob job) {
|
||||
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) throw new IllegalStateException("FILE_BUILT без local_archive_path");
|
||||
Path p = Path.of(job.localArchivePath());
|
||||
if (!Files.isRegularFile(p)) throw new IllegalStateException("Локальный archive-файл отсутствует: " + p);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static void require32(byte[] v, String name) {
|
||||
if (v == null || v.length != 32) throw new IllegalStateException(name + " должен быть 32 bytes");
|
||||
}
|
||||
private static String b64url(byte[] v) { return v == null ? "" : Base64.getUrlEncoder().withoutPadding().encodeToString(v); }
|
||||
private static String hex(byte[] b){StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||
}
|
||||
-259
@@ -1,259 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.*;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PSSParameterSpec;
|
||||
import java.security.spec.RSAPrivateCrtKeySpec;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Публикация готовых локальных SHINE-ARCHIVE файлов в Arweave v2.
|
||||
* Поддерживает стандартный data Merkle tree и загрузку через /chunk.
|
||||
*/
|
||||
public final class ArweaveArchiveService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveArchiveService.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder B64D = Base64.getUrlDecoder();
|
||||
|
||||
private final ArchivePublisherConfig cfg;
|
||||
private final HttpClient http;
|
||||
private volatile Wallet wallet;
|
||||
|
||||
public ArweaveArchiveService(ArchivePublisherConfig cfg) {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
}
|
||||
|
||||
public record UploadResult(String txId, byte[] txIdBytes, String gateway) {}
|
||||
|
||||
public UploadResult upload(Path archiveFile, String publisherLogin, long bigBlockNumber, byte[] archiveHash)
|
||||
throws Exception {
|
||||
long dataSize = Files.size(archiveFile);
|
||||
if (dataSize <= 0) throw new IllegalArgumentException("Пустой SHINE-ARCHIVE");
|
||||
Wallet w = wallet();
|
||||
String gateway = trimGateway(cfg.arweaveGateway());
|
||||
|
||||
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(archiveFile);
|
||||
String anchor = getRequiredText(gateway, "/tx_anchor");
|
||||
String reward = getRequiredText(gateway, "/price/" + dataSize);
|
||||
if (!reward.matches("^\\d+$")) throw new IOException("Arweave вернул некорректную цену: " + reward);
|
||||
verifyBalance(gateway, w.address(), reward);
|
||||
|
||||
List<List<byte[]>> tagList = new ArrayList<>();
|
||||
List<Map<String,String>> jsonTags = new ArrayList<>();
|
||||
addTag(jsonTags, tagList, "Content-Type", "application/octet-stream");
|
||||
addTag(jsonTags, tagList, "App-Name", "SHiNE");
|
||||
addTag(jsonTags, tagList, "SHiNE-Type", "archive");
|
||||
addTag(jsonTags, tagList, "SHiNE-Archive-Version", "1.0");
|
||||
addTag(jsonTags, tagList, "SHiNE-Publisher", publisherLogin);
|
||||
addTag(jsonTags, tagList, "SHiNE-Big-Block", Long.toUnsignedString(bigBlockNumber));
|
||||
addTag(jsonTags, tagList, "SHiNE-Archive-SHA256", hex(archiveHash));
|
||||
|
||||
byte[] signaturePayload = deepHash(List.of(
|
||||
utf8("2"),
|
||||
b64Decode(w.owner()),
|
||||
new byte[0],
|
||||
utf8("0"),
|
||||
utf8(reward),
|
||||
b64Decode(anchor),
|
||||
tagList,
|
||||
utf8(Long.toString(dataSize)),
|
||||
merkle.dataRoot()
|
||||
));
|
||||
byte[] rawSignature = signPayload(w.privateKey(), signaturePayload);
|
||||
byte[] txIdBytes = sha256(rawSignature);
|
||||
String txId = b64(txIdBytes);
|
||||
|
||||
Map<String,Object> tx = new LinkedHashMap<>();
|
||||
tx.put("format", 2);
|
||||
tx.put("id", txId);
|
||||
tx.put("last_tx", anchor);
|
||||
tx.put("owner", w.owner());
|
||||
tx.put("tags", jsonTags);
|
||||
tx.put("target", "");
|
||||
tx.put("quantity", "0");
|
||||
tx.put("data_root", b64(merkle.dataRoot()));
|
||||
tx.put("data_size", Long.toString(dataSize));
|
||||
tx.put("data", "");
|
||||
tx.put("reward", reward);
|
||||
tx.put("signature", b64(rawSignature));
|
||||
|
||||
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
|
||||
log.info("Arweave tx header принят: tx={}, chunks={}, bytes={}", txId, merkle.chunks().size(), dataSize);
|
||||
|
||||
for (int i=0; i<merkle.chunks().size(); i++) {
|
||||
ArweaveMerkle.Chunk chunkMeta = merkle.chunks().get(i);
|
||||
ArweaveMerkle.Proof proof = merkle.proofs().get(i);
|
||||
byte[] chunk = ArweaveMerkle.readChunk(archiveFile, chunkMeta);
|
||||
Map<String,Object> body = new LinkedHashMap<>();
|
||||
body.put("data_root", b64(merkle.dataRoot()));
|
||||
body.put("data_size", Long.toString(dataSize));
|
||||
body.put("data_path", b64(proof.proof()));
|
||||
body.put("offset", Long.toString(proof.offset()));
|
||||
body.put("chunk", b64(chunk));
|
||||
postChunkWithRetry(gateway, body, i, merkle.chunks().size());
|
||||
}
|
||||
return new UploadResult(txId, txIdBytes, gateway);
|
||||
}
|
||||
|
||||
/** Ждёт включения tx в блок и заданного количества подтверждений. */
|
||||
public int waitForConfirmations(String txId) throws Exception {
|
||||
if (cfg.arweaveMinConfirmations() <= 0) return 0;
|
||||
String gateway = trimGateway(cfg.arweaveGateway());
|
||||
long deadline = System.currentTimeMillis() + cfg.arweaveConfirmTimeoutMinutes() * 60_000L;
|
||||
int last = 0;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/tx/" + txId + "/status"))
|
||||
.timeout(Duration.ofSeconds(20)).GET().build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() == 200) {
|
||||
JsonNode json = MAPPER.readTree(resp.body());
|
||||
last = json.path("number_of_confirmations").asInt(0);
|
||||
if (last >= cfg.arweaveMinConfirmations()) return last;
|
||||
} else if (resp.statusCode() != 202 && resp.statusCode() != 404) {
|
||||
log.warn("Arweave status tx={} HTTP {}: {}", txId, resp.statusCode(), safe(resp.body()));
|
||||
}
|
||||
Thread.sleep(cfg.arweaveConfirmPollSeconds() * 1000L);
|
||||
}
|
||||
throw new IOException("Истёк timeout ожидания Arweave confirmations для " + txId + ", last=" + last);
|
||||
}
|
||||
|
||||
private void postChunkWithRetry(String gateway, Map<String,Object> body, int index, int total) throws Exception {
|
||||
Exception last = null;
|
||||
for (int attempt=1; attempt<=12; attempt++) {
|
||||
try {
|
||||
postJsonExpect2xx(gateway + "/chunk", body, Duration.ofSeconds(60));
|
||||
if ((index + 1) % 25 == 0 || index + 1 == total) {
|
||||
log.info("Arweave chunks: {}/{}", index + 1, total);
|
||||
}
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
last = e;
|
||||
if (attempt == 12) break;
|
||||
log.warn("Arweave chunk {}/{} attempt {}/12: {}", index + 1, total, attempt, e.getMessage());
|
||||
Thread.sleep(5_000L);
|
||||
}
|
||||
}
|
||||
throw new IOException("Не удалось загрузить Arweave chunk " + (index + 1) + "/" + total, last);
|
||||
}
|
||||
|
||||
private void postJsonExpect2xx(String url, Object body, Duration timeout) throws Exception {
|
||||
String json = MAPPER.writeValueAsString(body);
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(timeout)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw new IOException("HTTP " + resp.statusCode() + " " + safe(resp.body()));
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyBalance(String gateway, String address, String reward) throws Exception {
|
||||
if (address == null || address.isBlank()) return;
|
||||
String balance = getRequiredText(gateway, "/wallet/" + address + "/balance");
|
||||
if (balance.matches("^\\d+$") && new BigInteger(balance).compareTo(new BigInteger(reward)) < 0) {
|
||||
throw new IllegalStateException("Недостаточно AR: balance=" + balance + ", reward=" + reward);
|
||||
}
|
||||
}
|
||||
|
||||
private String getRequiredText(String gateway, String path) throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + path)).timeout(Duration.ofSeconds(20)).GET().build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw new IOException("Arweave HTTP " + resp.statusCode() + " для " + path + ": " + safe(resp.body()));
|
||||
}
|
||||
String s = resp.body() == null ? "" : resp.body().trim();
|
||||
if (s.isBlank()) throw new IOException("Пустой Arweave ответ для " + path);
|
||||
return s;
|
||||
}
|
||||
|
||||
private Wallet wallet() throws Exception {
|
||||
Wallet local = wallet;
|
||||
if (local != null) return local;
|
||||
synchronized (this) {
|
||||
if (wallet != null) return wallet;
|
||||
JsonNode jwk = MAPPER.readTree(Files.readString(cfg.arweaveWalletJwkPath(), StandardCharsets.UTF_8));
|
||||
String owner = required(jwk, "n");
|
||||
PrivateKey pk = buildPrivateKey(jwk);
|
||||
String address = b64(sha256(b64Decode(owner)));
|
||||
wallet = new Wallet(owner, address, pk);
|
||||
return wallet;
|
||||
}
|
||||
}
|
||||
|
||||
private static PrivateKey buildPrivateKey(JsonNode jwk) throws GeneralSecurityException {
|
||||
RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec(
|
||||
big(required(jwk,"n")), big(required(jwk,"e")), big(required(jwk,"d")),
|
||||
big(required(jwk,"p")), big(required(jwk,"q")), big(required(jwk,"dp")),
|
||||
big(required(jwk,"dq")), big(required(jwk,"qi")));
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(spec);
|
||||
}
|
||||
|
||||
private static byte[] signPayload(PrivateKey key, byte[] payload) throws GeneralSecurityException {
|
||||
Signature signature = Signature.getInstance("RSASSA-PSS");
|
||||
signature.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
|
||||
signature.initSign(key);
|
||||
signature.update(payload);
|
||||
return signature.sign();
|
||||
}
|
||||
|
||||
private static byte[] deepHash(Object data) throws GeneralSecurityException {
|
||||
if (data instanceof List<?> list) {
|
||||
byte[] tag = concat(utf8("list"), utf8(Integer.toString(list.size())));
|
||||
return deepHashChunks(list, sha384(tag));
|
||||
}
|
||||
if (!(data instanceof byte[] bytes)) throw new IllegalArgumentException("deepHash: ожидается byte[]/List");
|
||||
byte[] tag = concat(utf8("blob"), utf8(Integer.toString(bytes.length)));
|
||||
return sha384(concat(sha384(tag), sha384(bytes)));
|
||||
}
|
||||
|
||||
private static byte[] deepHashChunks(List<?> list, byte[] acc) throws GeneralSecurityException {
|
||||
byte[] cur = acc;
|
||||
for (Object item : list) cur = sha384(concat(cur, deepHash(item)));
|
||||
return cur;
|
||||
}
|
||||
|
||||
private static void addTag(List<Map<String,String>> json, List<List<byte[]>> raw, String name, String value) {
|
||||
byte[] n=utf8(name), v=utf8(value);
|
||||
Map<String,String> item = new LinkedHashMap<>();
|
||||
item.put("name", b64(n)); item.put("value", b64(v));
|
||||
json.add(item); raw.add(List.of(n,v));
|
||||
}
|
||||
|
||||
private static String required(JsonNode node, String field) {
|
||||
String s = node == null ? "" : node.path(field).asText("").trim();
|
||||
if (s.isBlank()) throw new IllegalStateException("В Arweave JWK отсутствует " + field);
|
||||
return s;
|
||||
}
|
||||
private static BigInteger big(String s) { return new BigInteger(1, b64Decode(s)); }
|
||||
private static String trimGateway(String s) { return (s == null ? "https://arweave.net" : s.trim()).replaceAll("/+$", ""); }
|
||||
private static byte[] utf8(String s) { return String.valueOf(s == null ? "" : s).getBytes(StandardCharsets.UTF_8); }
|
||||
private static byte[] b64Decode(String s) { return B64D.decode(s); }
|
||||
private static String b64(byte[] b) { return B64.encodeToString(b); }
|
||||
private static byte[] sha256(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-256").digest(b); }
|
||||
private static byte[] sha384(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-384").digest(b); }
|
||||
private static String hex(byte[] b) { StringBuilder s=new StringBuilder(); for(byte x:b)s.append(String.format("%02x",x)); return s.toString(); }
|
||||
private static byte[] concat(byte[]... arrays) { int n=0; for(byte[] a:arrays)n=Math.addExact(n,a.length); byte[] o=new byte[n]; int p=0; for(byte[]a:arrays){System.arraycopy(a,0,o,p,a.length);p+=a.length;} return o; }
|
||||
private static String safe(String s) { s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim(); return s.length()<=250?s:s.substring(0,250)+"..."; }
|
||||
|
||||
private record Wallet(String owner, String address, PrivateKey privateKey) {}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Fixed-delay publisher. Empty cycles are no-ops. */
|
||||
public final class ArweaveBlockPublisherScheduler {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherScheduler.class);
|
||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||
private static ScheduledExecutorService executor;
|
||||
|
||||
private ArweaveBlockPublisherScheduler() {}
|
||||
|
||||
public static void startOrLog() {
|
||||
ArweaveBlocksConfig cfg;
|
||||
try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); }
|
||||
catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; }
|
||||
if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled"); return; }
|
||||
if (!STARTED.compareAndSet(false,true)) return;
|
||||
try {
|
||||
ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg);
|
||||
executor = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "shine-arweave-block-publisher"); t.setDaemon(true); return t;
|
||||
});
|
||||
Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } };
|
||||
executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES);
|
||||
log.info("Arweave user-block publisher enabled: interval={}m gateway={}", cfg.publishIntervalMinutes(), cfg.publishGateway());
|
||||
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); }
|
||||
}
|
||||
|
||||
public static void close() { ScheduledExecutorService e=executor; if(e!=null)e.shutdownNow(); }
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.entities.BlockEntry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Publishes locally-created signed user DataItems as one standard ANS-104 bundle. */
|
||||
public final class ArweaveBlockPublisherService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class);
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||
private final ArweaveL1Uploader uploader;
|
||||
|
||||
public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) {
|
||||
this.cfg = cfg;
|
||||
this.uploader = new ArweaveL1Uploader(cfg);
|
||||
}
|
||||
|
||||
public int runCycle() throws Exception {
|
||||
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
|
||||
if (candidates.isEmpty()) return 0;
|
||||
|
||||
List<byte[]> items = new ArrayList<>();
|
||||
List<byte[]> ids = new ArrayList<>();
|
||||
long estimated = 32;
|
||||
for (BlockEntry e : candidates) {
|
||||
byte[] raw = e.getBlockBytes();
|
||||
byte[] id = e.getDataItemId();
|
||||
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||
long next = estimated + 64L + raw.length;
|
||||
if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break;
|
||||
if (next > cfg.publishMaxBundleBytes()) {
|
||||
log.error("One DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}",
|
||||
e.getBchName(), e.getBlockNumber(), raw.length);
|
||||
continue;
|
||||
}
|
||||
items.add(raw); ids.add(id); estimated = next;
|
||||
}
|
||||
if (items.isEmpty()) return 0;
|
||||
|
||||
byte[] bundle = Ans104Bundle.write(items);
|
||||
List<ArweaveL1Uploader.Tag> rootTags = List.of(
|
||||
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
|
||||
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
|
||||
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0"),
|
||||
// Deliberately different from App=test5590 so discovery returns child DataItems only.
|
||||
new ArweaveL1Uploader.Tag("App", "test5590-batch")
|
||||
);
|
||||
ArweaveL1Uploader.UploadResult result = uploader.upload(bundle, rootTags);
|
||||
blocksDAO.markArweavePublished(ids, result.txId(), System.currentTimeMillis());
|
||||
log.info("Published {} SHiNE test DataItems in root tx {} (bundle={} bytes)", items.size(), result.txId(), bundle.length);
|
||||
return items.size();
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class ArweaveBlockSyncScheduler {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncScheduler.class);
|
||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||
private static ScheduledExecutorService executor;
|
||||
private ArweaveBlockSyncScheduler() {}
|
||||
|
||||
public static void startOrLog() {
|
||||
ArweaveBlocksConfig cfg;
|
||||
try { cfg = ArweaveBlocksConfig.load(); }
|
||||
catch (Exception e) { log.error("Cannot read Arweave block sync config", e); return; }
|
||||
if (!cfg.syncEnabled()) { log.info("Arweave user-block sync disabled"); return; }
|
||||
if (!STARTED.compareAndSet(false,true)) return;
|
||||
try {
|
||||
ArweaveBlockSyncService service = new ArweaveBlockSyncService(cfg);
|
||||
executor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t=new Thread(r,"shine-arweave-block-sync");t.setDaemon(true);return t; });
|
||||
Runnable task=()->{try{service.runCycle();}catch(Exception e){log.error("Arweave block sync cycle failed",e);}};
|
||||
executor.scheduleWithFixedDelay(task,0,cfg.syncIntervalMinutes(),TimeUnit.MINUTES);
|
||||
log.info("Arweave user-block sync enabled: interval={}m gateway={}",cfg.syncIntervalMinutes(),cfg.syncGateway());
|
||||
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block sync failed to start",e); }
|
||||
}
|
||||
public static void close(){ScheduledExecutorService e=executor;if(e!=null)e.shutdownNow();}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
import blockchain.BchBlockEntry;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import shine.db.dao.ArweaveBlockImportDAO;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.SolanaUserPdaCurrentDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Discovers App=test5590 child DataItems, extracts their exact serialized bytes
|
||||
* from the root ANS-104 bundle and imports them through the normal AddBlock checks.
|
||||
*/
|
||||
public final class ArweaveBlockSyncService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance();
|
||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||
private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance();
|
||||
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||||
private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler();
|
||||
|
||||
public ArweaveBlockSyncService(ArweaveBlocksConfig cfg) { this.cfg = cfg; }
|
||||
|
||||
public void runCycle() throws Exception {
|
||||
discover();
|
||||
drainQueue();
|
||||
}
|
||||
|
||||
private void discover() throws Exception {
|
||||
long stored = importDAO.getLastBlockHeight();
|
||||
long minHeight = Math.max(cfg.syncStartBlockHeight(), stored);
|
||||
String cursor = null;
|
||||
long maxHeight = minHeight;
|
||||
Map<String, byte[]> rootCache = new HashMap<>();
|
||||
int discovered = 0;
|
||||
|
||||
do {
|
||||
JsonNode response = graphQlPage(minHeight, cursor);
|
||||
JsonNode txs = response.path("data").path("transactions");
|
||||
if (response.has("errors")) throw new IOException("Arweave GraphQL errors: " + response.path("errors"));
|
||||
JsonNode edges = txs.path("edges");
|
||||
if (!edges.isArray()) throw new IOException("Arweave GraphQL returned no transactions.edges");
|
||||
|
||||
String nextCursor = null;
|
||||
for (JsonNode edge : edges) {
|
||||
nextCursor = edge.path("cursor").asText(null);
|
||||
JsonNode node = edge.path("node");
|
||||
String itemIdText = node.path("id").asText("").trim();
|
||||
String rootTx = node.path("bundledIn").path("id").asText("").trim();
|
||||
long height = node.path("block").path("height").asLong(-1L);
|
||||
if (itemIdText.isBlank() || rootTx.isBlank() || height < 0) continue;
|
||||
maxHeight = Math.max(maxHeight, height);
|
||||
byte[] id32;
|
||||
try { id32 = B64URL.decode(itemIdText); }
|
||||
catch (IllegalArgumentException bad) { continue; }
|
||||
if (id32.length != 32 || blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
|
||||
|
||||
byte[] bundle = rootCache.computeIfAbsent(rootTx, key -> {
|
||||
try { return downloadRootBundle(key); }
|
||||
catch (Exception e) { throw new RootDownloadRuntimeException(e); }
|
||||
});
|
||||
byte[] raw = Ans104Bundle.find(bundle, id32, Math.max(cfg.publishMaxItems() * 4, 100_000));
|
||||
if (raw == null) throw new IOException("DataItem " + itemIdText + " not found in root bundle " + rootTx);
|
||||
Ans104DataItem parsed = new Ans104DataItem(raw);
|
||||
if (!parsed.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) continue;
|
||||
if (!parsed.verifySignature()) throw new IOException("Bad ANS-104 signature for " + itemIdText);
|
||||
importDAO.enqueueIfMissing(id32, rootTx, height, raw, System.currentTimeMillis());
|
||||
discovered++;
|
||||
}
|
||||
boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false);
|
||||
cursor = hasNext ? nextCursor : null;
|
||||
if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor");
|
||||
} while (cursor != null);
|
||||
|
||||
// Keep one-height overlap: the next query includes this height and deduplicates IDs.
|
||||
importDAO.setLastBlockHeight(maxHeight, System.currentTimeMillis());
|
||||
if (discovered > 0) log.info("Arweave discovery queued {} new SHiNE test DataItems through height {}", discovered, maxHeight);
|
||||
}
|
||||
|
||||
private void drainQueue() throws Exception {
|
||||
int passes = 0;
|
||||
int importedTotal = 0;
|
||||
boolean progress;
|
||||
do {
|
||||
progress = false;
|
||||
List<ArweaveBlockImportDAO.QueueItem> pending = importDAO.listPending(cfg.syncQueueBatchSize());
|
||||
if (pending.isEmpty()) break;
|
||||
for (ArweaveBlockImportDAO.QueueItem q : pending) {
|
||||
Ans104DataItem item;
|
||||
BchBlockEntry block;
|
||||
try {
|
||||
item = new Ans104DataItem(q.rawDataItem());
|
||||
if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch");
|
||||
if (!item.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) throw new IllegalArgumentException("bad App tag");
|
||||
if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature");
|
||||
block = new BchBlockEntry(q.rawDataItem());
|
||||
} catch (Exception e) {
|
||||
importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis());
|
||||
continue;
|
||||
}
|
||||
|
||||
SolanaUserPdaCurrentEntry user = usersDAO.getByBlockchainKey(item.owner32());
|
||||
if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) {
|
||||
importDAO.setPendingError(q.dataItemId(), "unknown_owner", System.currentTimeMillis());
|
||||
continue;
|
||||
}
|
||||
ensureState(user);
|
||||
Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(user.getBlockchainName(), q.rawDataItem());
|
||||
if (result.ok()) {
|
||||
importDAO.delete(q.dataItemId());
|
||||
importedTotal++; progress = true;
|
||||
continue;
|
||||
}
|
||||
String reason = result.reasonCode();
|
||||
if ("bad_block_number".equals(reason) || "bad_prev_hash".equals(reason)
|
||||
|| "prev_line_block_not_found".equals(reason) || "channel_not_found".equals(reason)
|
||||
|| "chain_resync_in_progress".equals(reason)) {
|
||||
importDAO.setPendingError(q.dataItemId(), reason, System.currentTimeMillis());
|
||||
} else {
|
||||
importDAO.reject(q.dataItemId(), reason, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
passes++;
|
||||
} while (progress && passes < 8);
|
||||
if (importedTotal > 0) log.info("Imported {} SHiNE test DataItems from Arweave", importedTotal);
|
||||
}
|
||||
|
||||
private void ensureState(SolanaUserPdaCurrentEntry user) throws Exception {
|
||||
BlockchainStateEntry existing = stateDAO.getByBlockchainName(user.getBlockchainName());
|
||||
if (existing != null) return;
|
||||
BlockchainStateEntry s = new BlockchainStateEntry();
|
||||
s.setBlockchainName(user.getBlockchainName()); s.setLogin(user.getLogin()); s.setBlockchainKey(user.getBlockchainKey());
|
||||
s.setSizeLimit(user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L);
|
||||
s.setFileSizeBytes(0L); s.setLastBlockNumber(-1); s.setLastBlockHash(null); s.setUpdatedAtMs(System.currentTimeMillis());
|
||||
stateDAO.insertIfMissing(s);
|
||||
}
|
||||
|
||||
private JsonNode graphQlPage(long minHeight, String cursor) throws Exception {
|
||||
String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\"";
|
||||
String query = "query { transactions(tags:[{name:\"App\",values:[\"test5590\"]}], block:{min:" + minHeight + "}, first:" + cfg.syncPageSize() + ", after:" + after + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id bundledIn { id } block { height } } } } }";
|
||||
String body = MAPPER.writeValueAsString(Map.of("query", query));
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql"))
|
||||
.timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("GraphQL HTTP " + resp.statusCode());
|
||||
return MAPPER.readTree(resp.body());
|
||||
}
|
||||
|
||||
private byte[] downloadRootBundle(String txId) throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/" + txId))
|
||||
.timeout(Duration.ofMinutes(5)).GET().build();
|
||||
HttpResponse<byte[]> resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Arweave root HTTP " + resp.statusCode() + " tx=" + txId);
|
||||
byte[] body = resp.body();
|
||||
if (body == null || body.length == 0) throw new IOException("Empty Arweave root bundle " + txId);
|
||||
if (body.length > cfg.syncMaxRootBundleBytes()) throw new IOException("Root bundle exceeds syncMaxRootBundleBytes: " + body.length);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");}
|
||||
private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");}
|
||||
private static final class RootDownloadRuntimeException extends RuntimeException { RootDownloadRuntimeException(Throwable cause){super(cause);} }
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package server.archive;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** Configuration of the new per-user-block ANS-104 Arweave transport. */
|
||||
public record ArweaveBlocksConfig(
|
||||
boolean publishEnabled,
|
||||
int publishIntervalMinutes,
|
||||
int publishMaxItems,
|
||||
long publishMaxBundleBytes,
|
||||
String publishGateway,
|
||||
Path walletJwkPath,
|
||||
int minConfirmations,
|
||||
int confirmPollSeconds,
|
||||
int confirmTimeoutMinutes,
|
||||
boolean syncEnabled,
|
||||
int syncIntervalMinutes,
|
||||
int syncPageSize,
|
||||
int syncQueueBatchSize,
|
||||
long syncStartBlockHeight,
|
||||
long syncMaxRootBundleBytes,
|
||||
String syncGateway
|
||||
) {
|
||||
public static final String TEST_TAG_NAME = "App";
|
||||
public static final String TEST_TAG_VALUE = "test5590";
|
||||
public static final String CHANNEL_TAG_NAME = "c";
|
||||
|
||||
public static ArweaveBlocksConfig load() {
|
||||
AppConfig c = AppConfig.getInstance();
|
||||
return new ArweaveBlocksConfig(
|
||||
c.getBoolean("arweave.blocks.publish.enabled", false),
|
||||
positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"),
|
||||
positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"),
|
||||
positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"),
|
||||
orDefault(c.getParam("arweave.blocks.publish.gateway"), "https://arweave.net"),
|
||||
optionalPath(c.getParam("arweave.blocks.publish.walletJwkPath")),
|
||||
nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"),
|
||||
positive(c.getInt("arweave.blocks.publish.confirmPollSeconds", 30), "publish.confirmPollSeconds"),
|
||||
positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"),
|
||||
c.getBoolean("arweave.blocks.sync.enabled", false),
|
||||
positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"),
|
||||
clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100),
|
||||
positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"),
|
||||
nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"),
|
||||
positiveLong(parseLong(c.getParam("arweave.blocks.sync.maxRootBundleBytes"), 256L * 1024 * 1024), "sync.maxRootBundleBytes"),
|
||||
orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com")
|
||||
);
|
||||
}
|
||||
|
||||
public void validatePublisher() {
|
||||
if (publishEnabled && walletJwkPath == null) {
|
||||
throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required when publisher is enabled");
|
||||
}
|
||||
}
|
||||
|
||||
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
|
||||
private static Path optionalPath(String v) { return v == null || v.isBlank() ? null : Path.of(v.trim()); }
|
||||
private static long parseLong(String v, long d) { return v == null || v.isBlank() ? d : Long.parseLong(v.trim()); }
|
||||
private static int positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
|
||||
private static int nonNegative(int v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;}
|
||||
private static long positiveLong(long v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
|
||||
private static long nonNegativeLong(long v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;}
|
||||
private static int clamp(int v,int min,int max){return Math.max(min,Math.min(max,v));}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package server.archive;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.*;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PSSParameterSpec;
|
||||
import java.security.spec.RSAPrivateCrtKeySpec;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/** Existing native Arweave L1 transport adapted to upload in-memory ANS-104 bundles. */
|
||||
public final class ArweaveL1Uploader {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveL1Uploader.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder B64D = Base64.getUrlDecoder();
|
||||
|
||||
public record Tag(String name, String value) {}
|
||||
public record UploadResult(String txId, byte[] txIdBytes) {}
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
private volatile Wallet wallet;
|
||||
|
||||
public ArweaveL1Uploader(ArweaveBlocksConfig cfg) { this.cfg = Objects.requireNonNull(cfg); }
|
||||
|
||||
public UploadResult upload(byte[] data, List<Tag> tags) throws Exception {
|
||||
if (data == null || data.length == 0) throw new IllegalArgumentException("Arweave upload data is empty");
|
||||
Wallet w = wallet();
|
||||
String gateway = trimGateway(cfg.publishGateway());
|
||||
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(data);
|
||||
String anchor = getRequiredText(gateway, "/tx_anchor");
|
||||
String reward = getRequiredText(gateway, "/price/" + data.length);
|
||||
if (!reward.matches("^\\d+$")) throw new IOException("Bad Arweave price: " + reward);
|
||||
verifyBalance(gateway, w.address(), reward);
|
||||
|
||||
List<List<byte[]>> tagList = new ArrayList<>();
|
||||
List<Map<String,String>> jsonTags = new ArrayList<>();
|
||||
for (Tag tag : tags == null ? List.<Tag>of() : tags) addTag(jsonTags, tagList, tag.name(), tag.value());
|
||||
|
||||
byte[] signaturePayload = deepHash(List.of(
|
||||
utf8("2"), b64Decode(w.owner()), new byte[0], utf8("0"), utf8(reward),
|
||||
b64Decode(anchor), tagList, utf8(Integer.toString(data.length)), merkle.dataRoot()
|
||||
));
|
||||
byte[] rawSignature = signPayload(w.privateKey(), signaturePayload);
|
||||
byte[] txIdBytes = sha256(rawSignature);
|
||||
String txId = b64(txIdBytes);
|
||||
|
||||
Map<String,Object> tx = new LinkedHashMap<>();
|
||||
tx.put("format",2); tx.put("id",txId); tx.put("last_tx",anchor); tx.put("owner",w.owner());
|
||||
tx.put("tags",jsonTags); tx.put("target",""); tx.put("quantity","0");
|
||||
tx.put("data_root",b64(merkle.dataRoot())); tx.put("data_size",Integer.toString(data.length)); tx.put("data","");
|
||||
tx.put("reward",reward); tx.put("signature",b64(rawSignature));
|
||||
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
|
||||
|
||||
for (int i=0;i<merkle.chunks().size();i++) {
|
||||
ArweaveMerkle.Chunk meta=merkle.chunks().get(i); ArweaveMerkle.Proof proof=merkle.proofs().get(i);
|
||||
Map<String,Object> body=new LinkedHashMap<>();
|
||||
body.put("data_root",b64(merkle.dataRoot())); body.put("data_size",Integer.toString(data.length));
|
||||
body.put("data_path",b64(proof.proof())); body.put("offset",Long.toString(proof.offset()));
|
||||
body.put("chunk",b64(ArweaveMerkle.readChunk(data,meta)));
|
||||
postChunkWithRetry(gateway,body,i,merkle.chunks().size());
|
||||
}
|
||||
if (cfg.minConfirmations() > 0) waitForConfirmations(txId);
|
||||
log.info("ANS-104 bundle uploaded to Arweave: tx={} bytes={} items-transported",txId,data.length);
|
||||
return new UploadResult(txId,txIdBytes);
|
||||
}
|
||||
|
||||
private int waitForConfirmations(String txId) throws Exception {
|
||||
String gateway=trimGateway(cfg.publishGateway());
|
||||
long deadline=System.currentTimeMillis()+cfg.confirmTimeoutMinutes()*60_000L; int last=0;
|
||||
while(System.currentTimeMillis()<deadline){
|
||||
HttpRequest req=HttpRequest.newBuilder(URI.create(gateway+"/tx/"+txId+"/status")).timeout(Duration.ofSeconds(20)).GET().build();
|
||||
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if(resp.statusCode()==200){JsonNode j=MAPPER.readTree(resp.body());last=j.path("number_of_confirmations").asInt(0);if(last>=cfg.minConfirmations())return last;}
|
||||
else if(resp.statusCode()!=202&&resp.statusCode()!=404)log.warn("Arweave status tx={} HTTP {}",txId,resp.statusCode());
|
||||
Thread.sleep(cfg.confirmPollSeconds()*1000L);
|
||||
}
|
||||
throw new IOException("Arweave confirmation timeout for "+txId+", last="+last);
|
||||
}
|
||||
|
||||
private void postChunkWithRetry(String gateway,Map<String,Object> body,int index,int total)throws Exception{
|
||||
Exception last=null;
|
||||
for(int attempt=1;attempt<=12;attempt++)try{postJsonExpect2xx(gateway+"/chunk",body,Duration.ofSeconds(60));return;}catch(Exception e){last=e;if(attempt==12)break;Thread.sleep(5000L);}
|
||||
throw new IOException("Failed Arweave chunk "+(index+1)+"/"+total,last);
|
||||
}
|
||||
private void postJsonExpect2xx(String url,Object body,Duration timeout)throws Exception{
|
||||
HttpRequest req=HttpRequest.newBuilder(URI.create(url)).timeout(timeout).header("Content-Type","application/json").header("Accept","application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(body),StandardCharsets.UTF_8)).build();
|
||||
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("HTTP "+resp.statusCode()+" "+safe(resp.body()));
|
||||
}
|
||||
private void verifyBalance(String gateway,String address,String reward)throws Exception{
|
||||
String balance=getRequiredText(gateway,"/wallet/"+address+"/balance");
|
||||
if(balance.matches("^\\d+$")&&new BigInteger(balance).compareTo(new BigInteger(reward))<0)throw new IllegalStateException("Not enough AR: balance="+balance+" reward="+reward);
|
||||
}
|
||||
private String getRequiredText(String gateway,String path)throws Exception{
|
||||
HttpRequest req=HttpRequest.newBuilder(URI.create(gateway+path)).timeout(Duration.ofSeconds(20)).GET().build();
|
||||
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("Arweave HTTP "+resp.statusCode()+" for "+path+": "+safe(resp.body()));
|
||||
String s=resp.body()==null?"":resp.body().trim();if(s.isBlank())throw new IOException("Empty Arweave response for "+path);return s;
|
||||
}
|
||||
private Wallet wallet()throws Exception{
|
||||
Wallet local=wallet;if(local!=null)return local;synchronized(this){if(wallet!=null)return wallet;
|
||||
JsonNode jwk=MAPPER.readTree(Files.readString(cfg.walletJwkPath(),StandardCharsets.UTF_8));String owner=required(jwk,"n");
|
||||
PrivateKey pk=buildPrivateKey(jwk);String address=b64(sha256(b64Decode(owner)));return wallet=new Wallet(owner,address,pk);}
|
||||
}
|
||||
private static PrivateKey buildPrivateKey(JsonNode jwk)throws GeneralSecurityException{
|
||||
RSAPrivateCrtKeySpec spec=new RSAPrivateCrtKeySpec(big(required(jwk,"n")),big(required(jwk,"e")),big(required(jwk,"d")),big(required(jwk,"p")),big(required(jwk,"q")),big(required(jwk,"dp")),big(required(jwk,"dq")),big(required(jwk,"qi")));
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(spec);
|
||||
}
|
||||
private static byte[] signPayload(PrivateKey key,byte[] payload)throws GeneralSecurityException{
|
||||
Signature s=Signature.getInstance("RSASSA-PSS");s.setParameter(new PSSParameterSpec("SHA-256","MGF1",MGF1ParameterSpec.SHA256,32,1));s.initSign(key);s.update(payload);return s.sign();
|
||||
}
|
||||
private static byte[] deepHash(Object data)throws GeneralSecurityException{
|
||||
if(data instanceof List<?> list){byte[] acc=sha384(utf8("list"+list.size()));for(Object item:list)acc=sha384(concat(acc,deepHash(item)));return acc;}
|
||||
if(!(data instanceof byte[] bytes))throw new IllegalArgumentException("deepHash byte[]/List expected");return sha384(concat(sha384(utf8("blob"+bytes.length)),sha384(bytes)));
|
||||
}
|
||||
private static void addTag(List<Map<String,String>> json,List<List<byte[]>> raw,String name,String value){byte[]n=utf8(name),v=utf8(value);Map<String,String>m=new LinkedHashMap<>();m.put("name",b64(n));m.put("value",b64(v));json.add(m);raw.add(List.of(n,v));}
|
||||
private static String required(JsonNode n,String f){String s=n==null?"":n.path(f).asText("").trim();if(s.isBlank())throw new IllegalStateException("Arweave JWK missing "+f);return s;}
|
||||
private static BigInteger big(String s){return new BigInteger(1,b64Decode(s));}
|
||||
private static String trimGateway(String s){return(s==null?"https://arweave.net":s.trim()).replaceAll("/+$","");}
|
||||
private static byte[] utf8(String s){return String.valueOf(s==null?"":s).getBytes(StandardCharsets.UTF_8);}
|
||||
private static byte[] b64Decode(String s){return B64D.decode(s);}private static String b64(byte[]b){return B64.encodeToString(b);}
|
||||
private static byte[] sha256(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-256").digest(b);}private static byte[] sha384(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-384").digest(b);}
|
||||
private static byte[] concat(byte[]...a){int n=0;for(byte[]x:a)n=Math.addExact(n,x.length);byte[]o=new byte[n];int p=0;for(byte[]x:a){System.arraycopy(x,0,o,p,x.length);p+=x.length;}return o;}
|
||||
private static String safe(String s){s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return s.length()<=250?s:s.substring(0,250)+"...";}
|
||||
private record Wallet(String owner,String address,PrivateKey privateKey){}
|
||||
}
|
||||
@@ -29,6 +29,55 @@ final class ArweaveMerkle {
|
||||
|
||||
private ArweaveMerkle() {}
|
||||
|
||||
|
||||
static Prepared prepare(byte[] data) throws IOException {
|
||||
if (data == null || data.length == 0) throw new IOException("Arweave data is empty");
|
||||
List<Chunk> chunks = chunkBytes(data);
|
||||
List<Node> leaves = new ArrayList<>(chunks.size());
|
||||
for (Chunk c : chunks) {
|
||||
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
|
||||
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
|
||||
}
|
||||
Node root = buildLayers(leaves);
|
||||
List<Proof> proofs = new ArrayList<>();
|
||||
resolveProofs(root, new byte[0], proofs);
|
||||
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
|
||||
chunks = new ArrayList<>(chunks);
|
||||
chunks.remove(chunks.size() - 1);
|
||||
proofs.remove(proofs.size() - 1);
|
||||
}
|
||||
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
|
||||
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
|
||||
}
|
||||
|
||||
private static List<Chunk> chunkBytes(byte[] data) {
|
||||
List<Chunk> out = new ArrayList<>();
|
||||
long remaining = data.length;
|
||||
long cursor = 0;
|
||||
int p = 0;
|
||||
while (remaining >= MAX_CHUNK_SIZE) {
|
||||
long chunkSize = MAX_CHUNK_SIZE;
|
||||
long next = remaining - MAX_CHUNK_SIZE;
|
||||
if (next > 0 && next < MIN_CHUNK_SIZE) chunkSize = (remaining + 1) / 2;
|
||||
byte[] chunk = Arrays.copyOfRange(data, p, p + (int) chunkSize);
|
||||
out.add(new Chunk(sha256(chunk), cursor, cursor + chunkSize));
|
||||
p += (int) chunkSize;
|
||||
cursor += chunkSize;
|
||||
remaining -= chunkSize;
|
||||
}
|
||||
byte[] rest = Arrays.copyOfRange(data, p, p + (int) remaining);
|
||||
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
|
||||
return out;
|
||||
}
|
||||
|
||||
static byte[] readChunk(byte[] data, Chunk c) throws IOException {
|
||||
long len = c.size();
|
||||
if (data == null || len < 0 || len > Integer.MAX_VALUE || c.minByteRange() < 0 || c.maxByteRange() > data.length) {
|
||||
throw new IOException("Invalid Arweave chunk range");
|
||||
}
|
||||
return Arrays.copyOfRange(data, (int) c.minByteRange(), (int) c.maxByteRange());
|
||||
}
|
||||
|
||||
static Prepared prepare(Path file) throws IOException {
|
||||
long fileSize = Files.size(file);
|
||||
List<Chunk> chunks = chunkFile(file, fileSize);
|
||||
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import sync.util.Base58Util;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
/** Строгий reader/verifier формата SHINE-ARCHIVE v1.0. */
|
||||
public final class ShineArchiveReader {
|
||||
public record Reference(long bigBlockNumber, byte[] archiveHash, String arweaveTxId) {}
|
||||
public record Chunk(long offset, long size, String blockchainName, List<byte[]> rawRecords,
|
||||
long previousBigBlockRef, long previousChunkOffset, long previousChunkSize) {}
|
||||
public record Document(long bigBlockNumber, long createdAtMs, String creatorLogin,
|
||||
List<Reference> references, List<Chunk> chunks, byte[] archiveHash) {}
|
||||
|
||||
public Document readAndVerify(Path file, String expectedPublisherLogin, byte[] expectedHash, String rootPublicKeyBase58) throws Exception {
|
||||
long fileSize = Files.size(file);
|
||||
if (fileSize < 13 + 38 + 1 + 32 + 64) throw new IOException("SHINE-ARCHIVE слишком короткий");
|
||||
if (fileSize >= 0x1_0000_0000L) throw new IOException("SHINE-ARCHIVE v1 >= 4 GiB не поддерживается");
|
||||
|
||||
byte[] computedHash = digestPrefix(file, fileSize - 96);
|
||||
try (RandomAccessFile in = new RandomAccessFile(file.toFile(), "r")) {
|
||||
byte[] magic = new byte[ShineArchiveWriter.MAGIC.length];
|
||||
in.readFully(magic);
|
||||
if (!Arrays.equals(magic, ShineArchiveWriter.MAGIC)) throw new IOException("Bad SHINE-ARCHIVE magic");
|
||||
int major=in.readUnsignedByte(), minor=in.readUnsignedByte();
|
||||
if (major != 1 || minor != 0) throw new IOException("Unsupported SHINE-ARCHIVE version " + major + "." + minor);
|
||||
long headerSize=u32(in); long bigBlock=u32(in); long createdAt=in.readLong();
|
||||
int creatorLen=in.readUnsignedByte(); int referencesMode=in.readUnsignedByte();
|
||||
long refsCount=u32(in); int refSize=in.readUnsignedShort(); long parentRef=u32(in);
|
||||
long chunksCount=u32(in); long recordsCount=u32(in);
|
||||
if (referencesMode != ShineArchiveWriter.REFERENCES_FULL || refSize != ShineArchiveWriter.REFERENCE_ENTRY_SIZE) {
|
||||
throw new IOException("Unsupported references table");
|
||||
}
|
||||
String creator=readUtf8(in,creatorLen);
|
||||
if (!creator.equalsIgnoreCase(String.valueOf(expectedPublisherLogin))) {
|
||||
throw new SecurityException("Archive creator " + creator + " != trusted publisher " + expectedPublisherLogin);
|
||||
}
|
||||
if (headerSize != in.getFilePointer()) throw new IOException("Некорректный header_size");
|
||||
if (refsCount > 10_000_000L || chunksCount > 10_000_000L) throw new IOException("Слишком много refs/chunks");
|
||||
|
||||
List<Reference> refs=new ArrayList<>((int)Math.min(refsCount, Integer.MAX_VALUE));
|
||||
for(long i=0;i<refsCount;i++) {
|
||||
long number=u32(in); byte[] hash=readFixed(in,32); byte[] tx=readFixed(in,32);
|
||||
refs.add(new Reference(number,hash,Base64.getUrlEncoder().withoutPadding().encodeToString(tx)));
|
||||
}
|
||||
if (refs.isEmpty() && parentRef != ShineArchiveWriter.NO_REFERENCE_U32) throw new IOException("Некорректный parent reference");
|
||||
if (!refs.isEmpty() && parentRef >= refs.size()) throw new IOException("parent_reference_index вне таблицы");
|
||||
|
||||
List<Chunk> chunks=new ArrayList<>((int)Math.min(chunksCount, Integer.MAX_VALUE));
|
||||
long parsedRecords=0;
|
||||
for(long ci=0;ci<chunksCount;ci++) {
|
||||
long chunkOffset=in.getFilePointer(); long chunkSize=u32(in); long chunkEnd=chunkOffset+chunkSize;
|
||||
if (chunkSize < 21 || chunkEnd > fileSize-96) throw new IOException("Некорректный chunk_size");
|
||||
int nameLen=in.readUnsignedByte(); String bch=readUtf8(in,nameLen); long count=u32(in);
|
||||
if (count > 10_000_000L) throw new IOException("Слишком много records в chunk " + bch);
|
||||
List<byte[]> records=new ArrayList<>((int)Math.min(count,Integer.MAX_VALUE));
|
||||
for(long ri=0;ri<count;ri++) {
|
||||
long n=u32(in); if(n<=0 || n>Integer.MAX_VALUE || in.getFilePointer()+n>chunkEnd-12) throw new IOException("Некорректный record_size");
|
||||
records.add(readFixed(in,(int)n)); parsedRecords++;
|
||||
}
|
||||
long prevRef=u32(in), prevOffset=u32(in), prevSize=u32(in);
|
||||
if (prevRef != ShineArchiveWriter.NO_REFERENCE_U32 && prevRef >= refs.size()) throw new IOException("previous_big_block_ref вне таблицы");
|
||||
if (in.getFilePointer()!=chunkEnd) throw new IOException("Chunk framing mismatch: " + bch);
|
||||
chunks.add(new Chunk(chunkOffset,chunkSize,bch,List.copyOf(records),prevRef,prevOffset,prevSize));
|
||||
}
|
||||
if (parsedRecords != recordsCount) throw new IOException("total_user_records_count mismatch");
|
||||
|
||||
int closerLen=in.readUnsignedByte(); String closer=readUtf8(in,closerLen);
|
||||
if(!closer.equals(creator)) throw new SecurityException("creator_login != closer_login");
|
||||
if(in.getFilePointer()!=fileSize-96) throw new IOException("Некорректная граница footer/hash");
|
||||
byte[] storedHash=readFixed(in,32); byte[] signature=readFixed(in,64);
|
||||
if(in.getFilePointer()!=fileSize) throw new IOException("Лишние данные после signature");
|
||||
if(!Arrays.equals(storedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с footer");
|
||||
if(expectedHash!=null && !Arrays.equals(expectedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с ожидаемым hash");
|
||||
byte[] root=Base58Util.decode(rootPublicKeyBase58);
|
||||
if(root.length!=32) throw new SecurityException("Некорректный root public key publisher-а");
|
||||
byte[] signed=new byte[ShineArchiveWriter.SIGNATURE_DOMAIN.length+32];
|
||||
System.arraycopy(ShineArchiveWriter.SIGNATURE_DOMAIN,0,signed,0,ShineArchiveWriter.SIGNATURE_DOMAIN.length);
|
||||
System.arraycopy(computedHash,0,signed,ShineArchiveWriter.SIGNATURE_DOMAIN.length,32);
|
||||
if(!Ed25519Util.verify(signed,signature,root)) throw new SecurityException("Не прошла подпись SHINE-ARCHIVE publisher-а");
|
||||
return new Document(bigBlock,createdAt,creator,List.copyOf(refs),List.copyOf(chunks),computedHash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] digestPrefix(Path file,long bytes) throws Exception {
|
||||
MessageDigest md=MessageDigest.getInstance("SHA-256");
|
||||
try(InputStream in=new BufferedInputStream(Files.newInputStream(file))) {
|
||||
byte[] buf=new byte[1024*1024]; long left=bytes;
|
||||
while(left>0){int n=in.read(buf,0,(int)Math.min(buf.length,left)); if(n<0) throw new EOFException(); md.update(buf,0,n); left-=n;}
|
||||
}
|
||||
return md.digest();
|
||||
}
|
||||
private static long u32(RandomAccessFile in) throws IOException { return Integer.toUnsignedLong(in.readInt()); }
|
||||
private static byte[] readFixed(RandomAccessFile in,int n) throws IOException { byte[] b=new byte[n]; in.readFully(b); return b; }
|
||||
private static String readUtf8(RandomAccessFile in,int n) throws IOException { return new String(readFixed(in,n), StandardCharsets.UTF_8); }
|
||||
}
|
||||
-310
@@ -1,310 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchivePublishJobChain;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Writer бинарного SHINE-ARCHIVE v1.0.
|
||||
*
|
||||
* Один blockchain_name встречается максимум один раз и содержит все новые raw records этой цепочки.
|
||||
*/
|
||||
public final class ShineArchiveWriter {
|
||||
public static final byte VERSION_MAJOR = 1;
|
||||
public static final byte VERSION_MINOR = 0;
|
||||
public static final byte REFERENCES_FULL = 0;
|
||||
public static final long NO_REFERENCE_U32 = 0xffff_ffffL;
|
||||
public static final int REFERENCE_ENTRY_SIZE = 68;
|
||||
public static final byte[] MAGIC = "SHINE-ARCHIVE".getBytes(StandardCharsets.US_ASCII);
|
||||
public static final byte[] SIGNATURE_DOMAIN = "SHINE-ARCHIVE-V1".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
public record ChunkPosition(long offset, long size) {}
|
||||
public record BuildResult(Path file, byte[] archiveHash, Map<String, ChunkPosition> chunks, long fileSize) {}
|
||||
|
||||
public BuildResult build(
|
||||
Path finalPath,
|
||||
String creatorLogin,
|
||||
long bigBlockNumber,
|
||||
long createdAtMs,
|
||||
List<ArchiveBigBlockRef> previousBigBlocks,
|
||||
List<ArchivePublishJobChain> chains,
|
||||
Map<String, List<BlockEntry>> recordsByBlockchain,
|
||||
byte[] signingPrivateSeed32,
|
||||
long maxFileBytes
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(finalPath, "finalPath");
|
||||
Objects.requireNonNull(previousBigBlocks, "previousBigBlocks");
|
||||
Objects.requireNonNull(chains, "chains");
|
||||
Objects.requireNonNull(recordsByBlockchain, "recordsByBlockchain");
|
||||
requireU32(bigBlockNumber, "big_block_number");
|
||||
if (signingPrivateSeed32 == null || signingPrivateSeed32.length != 32) {
|
||||
throw new IllegalArgumentException("Archive signing key должен быть 32-byte Ed25519 seed");
|
||||
}
|
||||
|
||||
byte[] loginBytes = utf8U8(creatorLogin, "creator_login");
|
||||
if (previousBigBlocks.size() > 0xffff_ffffL || chains.size() > 0xffff_ffffL) {
|
||||
throw new IllegalArgumentException("Слишком много references/chunks для v1");
|
||||
}
|
||||
|
||||
List<ArchivePublishJobChain> sortedChains = new ArrayList<>(chains);
|
||||
sortedChains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
|
||||
|
||||
long totalRecords = 0;
|
||||
for (ArchivePublishJobChain ch : sortedChains) {
|
||||
List<BlockEntry> rs = recordsByBlockchain.get(ch.blockchainName());
|
||||
if (rs == null || rs.isEmpty()) {
|
||||
throw new IllegalArgumentException("Нет frozen records для " + ch.blockchainName());
|
||||
}
|
||||
totalRecords += rs.size();
|
||||
requireU32(totalRecords, "total_user_records_count");
|
||||
}
|
||||
|
||||
Map<Long, Integer> referenceIndexByBlock = new HashMap<>();
|
||||
for (int i = 0; i < previousBigBlocks.size(); i++) {
|
||||
ArchiveBigBlockRef ref = previousBigBlocks.get(i);
|
||||
requireU32(ref.bigBlockNumber(), "PreviousBigBlockReference.big_block_number");
|
||||
require32(ref.archiveHash(), "PreviousBigBlockReference.big_block_hash");
|
||||
require32(ref.arweaveTxId(), "PreviousBigBlockReference.arweave_tx_id");
|
||||
referenceIndexByBlock.put(ref.bigBlockNumber(), i);
|
||||
}
|
||||
|
||||
Files.createDirectories(finalPath.toAbsolutePath().getParent());
|
||||
Path tmp = finalPath.resolveSibling(finalPath.getFileName() + ".tmp");
|
||||
Files.deleteIfExists(tmp);
|
||||
|
||||
Map<String, ChunkPosition> positions = new LinkedHashMap<>();
|
||||
MessageDigest digest = sha256();
|
||||
|
||||
try (OutputStream fileOut = new BufferedOutputStream(Files.newOutputStream(tmp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE));
|
||||
CountingOutputStream counting = new CountingOutputStream(fileOut);
|
||||
DigestOutputStream digestOut = new DigestOutputStream(counting, digest);
|
||||
DataOutputStream out = new DataOutputStream(digestOut)) {
|
||||
|
||||
// ---------- header ----------
|
||||
out.write(MAGIC);
|
||||
out.writeByte(VERSION_MAJOR);
|
||||
out.writeByte(VERSION_MINOR);
|
||||
|
||||
long headerSize = fixedHeaderSize() + loginBytes.length;
|
||||
writeU32(out, headerSize);
|
||||
writeU32(out, bigBlockNumber);
|
||||
out.writeLong(createdAtMs);
|
||||
out.writeByte(loginBytes.length);
|
||||
out.writeByte(REFERENCES_FULL);
|
||||
writeU32(out, previousBigBlocks.size());
|
||||
out.writeShort(REFERENCE_ENTRY_SIZE);
|
||||
long parentIndex = previousBigBlocks.isEmpty() ? NO_REFERENCE_U32 : previousBigBlocks.size() - 1L;
|
||||
writeU32(out, parentIndex);
|
||||
writeU32(out, sortedChains.size());
|
||||
writeU32(out, totalRecords);
|
||||
out.write(loginBytes);
|
||||
|
||||
// ---------- полный список предыдущих больших блоков ----------
|
||||
for (ArchiveBigBlockRef ref : previousBigBlocks) {
|
||||
writeU32(out, ref.bigBlockNumber());
|
||||
out.write(ref.archiveHash());
|
||||
out.write(ref.arweaveTxId());
|
||||
}
|
||||
|
||||
// ---------- по одному chunk на blockchain_name ----------
|
||||
for (ArchivePublishJobChain ch : sortedChains) {
|
||||
List<BlockEntry> records = new ArrayList<>(recordsByBlockchain.get(ch.blockchainName()));
|
||||
records.sort(Comparator.comparingInt(BlockEntry::getBlockNumber));
|
||||
validateFrozenRange(ch, records);
|
||||
|
||||
byte[] name = utf8U8(ch.blockchainName(), "blockchain_name");
|
||||
long chunkSize = calculateChunkSize(name.length, records);
|
||||
requireU32(chunkSize, "chunk_size");
|
||||
long chunkOffset = counting.getCount();
|
||||
requireU32(chunkOffset, "chunk_offset");
|
||||
if (chunkOffset + chunkSize > maxFileBytes) {
|
||||
throw new ArchiveTooLargeException("Большой архив превысит archive.maxFileBytes: " + (chunkOffset + chunkSize));
|
||||
}
|
||||
|
||||
writeU32(out, chunkSize);
|
||||
out.writeByte(name.length);
|
||||
out.write(name);
|
||||
writeU32(out, records.size());
|
||||
for (BlockEntry record : records) {
|
||||
byte[] raw = record.getBlockBytes();
|
||||
if (raw == null) throw new IllegalArgumentException("block_bytes=NULL: " + ch.blockchainName() + "#" + record.getBlockNumber());
|
||||
writeU32(out, raw.length);
|
||||
out.write(raw);
|
||||
}
|
||||
|
||||
if (ch.previousArchiveBigBlockNumber() == null) {
|
||||
writeU32(out, NO_REFERENCE_U32);
|
||||
writeU32(out, 0);
|
||||
writeU32(out, 0);
|
||||
} else {
|
||||
Integer refIndex = referenceIndexByBlock.get(ch.previousArchiveBigBlockNumber());
|
||||
if (refIndex == null) {
|
||||
throw new IllegalStateException("Предыдущий big block blockchain отсутствует в FULL reference table: "
|
||||
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
|
||||
}
|
||||
ArchiveBigBlockRef previousRef = previousBigBlocks.get(refIndex);
|
||||
require32(ch.previousArchiveBigBlockHash(), "previous_archive_big_block_hash " + ch.blockchainName());
|
||||
if (!Arrays.equals(previousRef.archiveHash(), ch.previousArchiveBigBlockHash())) {
|
||||
throw new IllegalStateException("Hash предыдущего big block не совпадает с FULL reference table: "
|
||||
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
|
||||
}
|
||||
if (ch.previousChunkOffset() == null || ch.previousChunkSize() == null) {
|
||||
throw new IllegalStateException("Нет предыдущих chunk offset/size: " + ch.blockchainName());
|
||||
}
|
||||
writeU32(out, refIndex);
|
||||
writeU32(out, ch.previousChunkOffset());
|
||||
writeU32(out, ch.previousChunkSize());
|
||||
}
|
||||
positions.put(ch.blockchainName(), new ChunkPosition(chunkOffset, chunkSize));
|
||||
}
|
||||
|
||||
// ---------- footer, login входит в hash ----------
|
||||
out.writeByte(loginBytes.length);
|
||||
out.write(loginBytes);
|
||||
out.flush();
|
||||
|
||||
byte[] archiveHash = digest.digest();
|
||||
byte[] signedPayload = new byte[SIGNATURE_DOMAIN.length + archiveHash.length];
|
||||
System.arraycopy(SIGNATURE_DOMAIN, 0, signedPayload, 0, SIGNATURE_DOMAIN.length);
|
||||
System.arraycopy(archiveHash, 0, signedPayload, SIGNATURE_DOMAIN.length, archiveHash.length);
|
||||
byte[] signature = Ed25519Util.sign(signedPayload, signingPrivateSeed32);
|
||||
|
||||
// hash/signature не должны попасть в hash самого блока.
|
||||
digestOut.on(false);
|
||||
out.write(archiveHash);
|
||||
out.write(signature);
|
||||
out.flush();
|
||||
|
||||
long finalSize = counting.getCount();
|
||||
if (finalSize >= 0x1_0000_0000L || finalSize > maxFileBytes) {
|
||||
throw new ArchiveTooLargeException("Размер SHINE-ARCHIVE v1 превышает лимит: " + finalSize);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Files.deleteIfExists(tmp);
|
||||
if (e instanceof IOException ioe) throw ioe;
|
||||
throw new IOException("Не удалось собрать SHINE-ARCHIVE", e);
|
||||
}
|
||||
|
||||
// До Arweave upload существует уже готовый локальный файл с окончательным именем.
|
||||
try {
|
||||
Files.move(tmp, finalPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, finalPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
byte[] hash = readStoredArchiveHash(finalPath);
|
||||
long size = Files.size(finalPath);
|
||||
return new BuildResult(finalPath, hash, Collections.unmodifiableMap(positions), size);
|
||||
}
|
||||
|
||||
/** Проверяет локальный файл перед оплатой Arweave. */
|
||||
public static byte[] verifyLocalFile(Path file) throws IOException {
|
||||
long size = Files.size(file);
|
||||
if (size < MAGIC.length + 2 + 96) throw new IOException("Слишком короткий SHINE-ARCHIVE: " + file);
|
||||
byte[] stored = readStoredArchiveHash(file);
|
||||
MessageDigest md = sha256();
|
||||
long hashInputSize = size - 96; // [archive_hash 32][signature 64]
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
byte[] buf = new byte[1024 * 1024];
|
||||
long left = hashInputSize;
|
||||
while (left > 0) {
|
||||
int n = in.read(buf, 0, (int)Math.min(buf.length, left));
|
||||
if (n < 0) throw new EOFException("Неожиданный EOF при проверке архива");
|
||||
md.update(buf, 0, n);
|
||||
left -= n;
|
||||
}
|
||||
}
|
||||
byte[] actual = md.digest();
|
||||
if (!Arrays.equals(stored, actual)) throw new IOException("SHA-256 локального SHINE-ARCHIVE не совпадает с footer");
|
||||
return actual;
|
||||
}
|
||||
|
||||
public static byte[] readStoredArchiveHash(Path file) throws IOException {
|
||||
long size = Files.size(file);
|
||||
if (size < 96) throw new EOFException("Файл слишком короткий");
|
||||
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
|
||||
raf.seek(size - 96);
|
||||
byte[] hash = new byte[32];
|
||||
raf.readFully(hash);
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
private static long fixedHeaderSize() {
|
||||
// magic + major/minor + headerSize + blockNo + time + creatorLen + mode + refCount + refSize + parentIndex + chunkCount + recordCount
|
||||
return 13L + 1 + 1 + 4 + 4 + 8 + 1 + 1 + 4 + 2 + 4 + 4 + 4;
|
||||
}
|
||||
|
||||
private static long calculateChunkSize(int nameLen, List<BlockEntry> records) {
|
||||
long size = 4L + 1L + nameLen + 4L + 12L;
|
||||
for (BlockEntry e : records) {
|
||||
byte[] raw = e.getBlockBytes();
|
||||
if (raw == null) throw new IllegalArgumentException("raw record is null");
|
||||
size += 4L + raw.length;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
private static void validateFrozenRange(ArchivePublishJobChain ch, List<BlockEntry> records) {
|
||||
long expected = ch.toSourceBlockNumber() - ch.fromSourceBlockNumber() + 1;
|
||||
if (expected <= 0 || expected != records.size()) {
|
||||
throw new IllegalArgumentException("Frozen range неполон для " + ch.blockchainName()
|
||||
+ ": ожидалось=" + expected + ", получено=" + records.size());
|
||||
}
|
||||
long n = ch.fromSourceBlockNumber();
|
||||
for (BlockEntry e : records) {
|
||||
if (Integer.toUnsignedLong(e.getBlockNumber()) != n) {
|
||||
throw new IllegalArgumentException("Нарушена непрерывность " + ch.blockchainName()
|
||||
+ ": ожидался block " + n + ", получен " + Integer.toUnsignedLong(e.getBlockNumber()));
|
||||
}
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] utf8U8(String value, String name) {
|
||||
if (value == null) throw new IllegalArgumentException(name + " == null");
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length == 0 || bytes.length > 255) throw new IllegalArgumentException(name + " должен занимать 1..255 UTF-8 байт");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void require32(byte[] v, String name) {
|
||||
if (v == null || v.length != 32) throw new IllegalArgumentException(name + " должен быть 32 bytes");
|
||||
}
|
||||
|
||||
private static void requireU32(long v, String name) {
|
||||
if (v < 0 || v > 0xffff_ffffL) throw new IllegalArgumentException(name + " вне u32: " + v);
|
||||
}
|
||||
|
||||
private static void writeU32(DataOutputStream out, long v) throws IOException {
|
||||
requireU32(v, "u32");
|
||||
out.writeInt((int)v); // DataOutputStream = big-endian; битовый шаблон корректен и для unsigned > 2^31.
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try { return MessageDigest.getInstance("SHA-256"); }
|
||||
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
|
||||
/** OutputStream со счётчиком без ограничения int. */
|
||||
private static final class CountingOutputStream extends FilterOutputStream {
|
||||
private long count;
|
||||
CountingOutputStream(OutputStream out) { super(out); }
|
||||
long getCount() { return count; }
|
||||
@Override public void write(int b) throws IOException { out.write(b); count++; }
|
||||
@Override public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); count += len; }
|
||||
}
|
||||
|
||||
public static final class ArchiveTooLargeException extends IOException {
|
||||
public ArchiveTooLargeException(String message) { super(message); }
|
||||
}
|
||||
}
|
||||
-373
@@ -1,373 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sync.codec.ShineUsersCodec;
|
||||
import sync.codec.ShineUsersCodec.UserPdaSnapshot;
|
||||
import sync.util.Base58Util;
|
||||
import sync.util.SolanaPdaUtil;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Обновляет ArchiveHeadBlock (type=100) обычной инструкцией update_user_pda.
|
||||
* Отдельной Solana-инструкции для архива нет.
|
||||
*/
|
||||
public final class SolanaArchiveHeadWriter {
|
||||
private static final Logger log = LoggerFactory.getLogger(SolanaArchiveHeadWriter.class);
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private static final String SYSTEM_PROGRAM = "11111111111111111111111111111111";
|
||||
private static final String ED25519_PROGRAM = "Ed25519SigVerify111111111111111111111111111";
|
||||
private static final String COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111";
|
||||
private static final String SYSVAR_INSTRUCTIONS = "Sysvar1nstructions1111111111111111111111111";
|
||||
private static final String USER_SEED_PREFIX = "user_login=";
|
||||
private static final String ECONOMY_SEED = "shine_users_economy_config";
|
||||
private static final String INFLOW_SEED = "shine_payments_inflow_vault";
|
||||
private static final byte[] LAST_BLOCK_STATE_PREFIX = "SHiNE_LAST_BLOCK".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private final ArchivePublisherConfig cfg;
|
||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
private final byte[] rootSeed32;
|
||||
private final byte[] clientSeed32;
|
||||
|
||||
public SolanaArchiveHeadWriter(ArchivePublisherConfig cfg) throws IOException {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
this.rootSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
|
||||
this.clientSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaClientKeyPath());
|
||||
}
|
||||
|
||||
public record SubmitResult(String signature, String userPda) {}
|
||||
|
||||
/** Preflight: проверяет, что локальные root/client private keys соответствуют текущему User PDA. */
|
||||
public void validateKeysAgainstPda() throws Exception {
|
||||
Current current = loadCurrent();
|
||||
byte[] expectedRoot = Base58Util.decode(current.snapshot.rootKey());
|
||||
byte[] expectedClient = Base58Util.decode(current.snapshot.clientKey());
|
||||
if (!Arrays.equals(expectedRoot, Ed25519Util.derivePublicKey(rootSeed32))) {
|
||||
throw new IllegalStateException("archive.solana.rootKeyPath не соответствует root_key User PDA " + cfg.serverLogin());
|
||||
}
|
||||
if (!Arrays.equals(expectedClient, Ed25519Util.derivePublicKey(clientSeed32))) {
|
||||
throw new IllegalStateException("archive.solana.clientKeyPath не соответствует client_key User PDA " + cfg.serverLogin());
|
||||
}
|
||||
}
|
||||
|
||||
public SubmitResult submitArchiveHead(byte[] arweaveTxId32, byte[] archiveHash32) throws Exception {
|
||||
require32(arweaveTxId32, "Arweave TX ID");
|
||||
require32(archiveHash32, "archive hash");
|
||||
|
||||
Current current = loadCurrent();
|
||||
UserPdaSnapshot old = current.snapshot;
|
||||
byte[] rootPub = Ed25519Util.derivePublicKey(rootSeed32);
|
||||
byte[] clientPub = Ed25519Util.derivePublicKey(clientSeed32);
|
||||
if (!Arrays.equals(rootPub, Base58Util.decode(old.rootKey()))) {
|
||||
throw new IllegalStateException("Root key archive publisher-а не совпадает с User PDA");
|
||||
}
|
||||
if (!Arrays.equals(clientPub, Base58Util.decode(old.clientKey()))) {
|
||||
throw new IllegalStateException("Client key archive publisher-а не совпадает с User PDA");
|
||||
}
|
||||
|
||||
byte[] currentRaw = Base64.getDecoder().decode(old.rawDataBase64());
|
||||
int currentRecordLen = u16le(currentRaw, 7);
|
||||
byte[] currentUnsigned = Arrays.copyOf(currentRaw, currentRecordLen - 64);
|
||||
byte[] prevHash = sha256(currentUnsigned);
|
||||
|
||||
long updatedAt = System.currentTimeMillis();
|
||||
String txB64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(arweaveTxId32);
|
||||
String archiveHashHex = hex(archiveHash32);
|
||||
String zeroSig = Base58Util.encode(new byte[64]);
|
||||
|
||||
UserPdaSnapshot nextUnsignedSnapshot = new UserPdaSnapshot(
|
||||
old.pdaAddress(), old.login(), old.recordNumber() + 1, old.slot(), "",
|
||||
old.recoveryKey(), old.rootKey(), old.clientKey(), old.blockchainName(), old.blockchainKey(),
|
||||
old.paidLimitBytes(), old.usedBytes(), old.lastBlockNumber(), old.lastBlockHash(),
|
||||
old.lastBlockSignature(), old.arweaveTxId(), txB64Url, archiveHashHex,
|
||||
old.isServer(), old.addressFormatType(), old.addressFormatVersion(), old.serverAddress(),
|
||||
old.syncServers(), old.accessServers(), old.sessionsMode(), old.sessions(), old.trustedCount(),
|
||||
old.createdAtMs(), updatedAt, hex(prevHash), zeroSig, ""
|
||||
);
|
||||
byte[] nextFull = Base64.getDecoder().decode(ShineUsersCodec.serializeSnapshotToBase64(nextUnsignedSnapshot));
|
||||
int nextRecordLen = u16le(nextFull, 7);
|
||||
byte[] nextUnsigned = Arrays.copyOf(nextFull, nextRecordLen - 64);
|
||||
byte[] nextUnsignedHash = sha256(nextUnsigned);
|
||||
byte[] rootSignature = Ed25519Util.sign(nextUnsignedHash, rootSeed32);
|
||||
|
||||
byte[] updateData = buildUpdateInstruction(old, updatedAt, prevHash, arweaveTxId32, archiveHash32, rootSignature);
|
||||
byte[] lastStateHash = sha256(buildLastBlockState(old));
|
||||
byte[] lastBlockSignature = Base58Util.decode(old.lastBlockSignature());
|
||||
byte[] blockchainPub = Base58Util.decode(old.blockchainKey());
|
||||
|
||||
byte[] rootEdData = buildEd25519Instruction(rootSignature, rootPub, nextUnsignedHash);
|
||||
byte[] bchEdData = buildEd25519Instruction(lastBlockSignature, blockchainPub, lastStateHash);
|
||||
|
||||
String blockhash = getLatestBlockhash();
|
||||
byte[] message = buildLegacyMessage(current.userPda, updateData, rootEdData, bchEdData, blockhash, clientPub);
|
||||
byte[] clientSignature = Ed25519Util.sign(message, clientSeed32);
|
||||
byte[] transaction = serializeTransaction(clientSignature, message);
|
||||
String signature = sendTransaction(transaction);
|
||||
log.info("Archive head отправлен в Solana: login={} pda={} signature={}", cfg.serverLogin(), current.userPda, signature);
|
||||
return new SubmitResult(signature, current.userPda);
|
||||
}
|
||||
|
||||
public void waitFinalized(String signature) throws Exception {
|
||||
long deadline = System.currentTimeMillis() + cfg.solanaConfirmTimeoutMinutes() * 60_000L;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
JsonNode result = rpc("getSignatureStatuses", List.of(List.of(signature), Map.of("searchTransactionHistory", true))).path("result");
|
||||
JsonNode values = result.path("value");
|
||||
JsonNode status = values.isArray() && values.size() > 0 ? values.get(0) : null;
|
||||
if (status != null && !status.isNull()) {
|
||||
if (status.hasNonNull("err")) {
|
||||
throw new IOException("Solana archive-head tx завершилась ошибкой: " + status.path("err"));
|
||||
}
|
||||
String confirmation = status.path("confirmationStatus").asText("");
|
||||
if ("finalized".equals(confirmation)) return;
|
||||
}
|
||||
Thread.sleep(cfg.solanaConfirmPollSeconds() * 1000L);
|
||||
}
|
||||
throw new IOException("Timeout ожидания Solana finalized: " + signature);
|
||||
}
|
||||
|
||||
public boolean currentHeadMatches(byte[] txId32, byte[] hash32) throws Exception {
|
||||
Current current = loadCurrent();
|
||||
if (current.snapshot.archiveHeadTxId() == null || current.snapshot.archiveHeadTxId().isBlank()) return false;
|
||||
byte[] currentTx = Base64.getUrlDecoder().decode(current.snapshot.archiveHeadTxId());
|
||||
byte[] currentHash = unhex(current.snapshot.archiveHeadHash());
|
||||
return Arrays.equals(currentTx, txId32) && Arrays.equals(currentHash, hash32);
|
||||
}
|
||||
|
||||
private Current loadCurrent() throws Exception {
|
||||
String normalized = cfg.serverLogin().trim().toLowerCase(Locale.ROOT);
|
||||
String userPda = SolanaPdaUtil.findProgramAddress(
|
||||
List.of(USER_SEED_PREFIX.getBytes(StandardCharsets.UTF_8), normalized.getBytes(StandardCharsets.UTF_8)),
|
||||
cfg.solanaUsersProgramId());
|
||||
JsonNode accountResult = rpc("getAccountInfo", List.of(userPda, Map.of("encoding", "base64", "commitment", cfg.solanaCommitment()))).path("result");
|
||||
JsonNode value = accountResult.path("value");
|
||||
if (value.isMissingNode() || value.isNull()) throw new IOException("User PDA не найден для " + cfg.serverLogin());
|
||||
String owner = value.path("owner").asText("");
|
||||
if (!cfg.solanaUsersProgramId().equals(owner)) throw new IOException("User PDA принадлежит другой программе: " + owner);
|
||||
JsonNode data = value.path("data");
|
||||
if (!data.isArray() || data.size() < 1) throw new IOException("Некорректный getAccountInfo.data");
|
||||
String rawB64 = data.get(0).asText();
|
||||
UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(userPda, 0L, rawB64, "");
|
||||
return new Current(userPda, snapshot);
|
||||
}
|
||||
|
||||
private byte[] buildUpdateInstruction(UserPdaSnapshot s, long updatedAt, byte[] prevHash,
|
||||
byte[] archiveTx, byte[] archiveHash, byte[] rootSig) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(4); // IX_UPDATE_USER_PDA
|
||||
strU8(out, s.login());
|
||||
out.write(Base58Util.decode(s.recoveryKey()));
|
||||
out.write(Base58Util.decode(s.rootKey()));
|
||||
u64le(out, s.createdAtMs());
|
||||
u64le(out, updatedAt);
|
||||
u32le(out, Integer.toUnsignedLong(s.recordNumber() + 1));
|
||||
out.write(prevHash);
|
||||
u64le(out, 0L); // additional_limit
|
||||
out.write(Base58Util.decode(s.clientKey()));
|
||||
out.write(Base58Util.decode(s.blockchainKey()));
|
||||
strU8(out, s.blockchainName());
|
||||
u64le(out, s.usedBytes());
|
||||
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
|
||||
out.write(unhex(s.lastBlockHash()));
|
||||
out.write(Base58Util.decode(s.lastBlockSignature()));
|
||||
strU8(out, s.arweaveTxId());
|
||||
out.write(s.isServer() ? 1 : 0);
|
||||
if (s.isServer()) {
|
||||
out.write(s.addressFormatType() & 0xff);
|
||||
out.write(s.addressFormatVersion() & 0xff);
|
||||
strU8(out, s.serverAddress());
|
||||
if (s.syncServers().size() > 255) throw new IOException("Слишком много sync_servers");
|
||||
out.write(s.syncServers().size());
|
||||
for (String v : s.syncServers()) strU8(out, v);
|
||||
}
|
||||
if (s.accessServers().size() > 255) throw new IOException("Слишком много access_servers");
|
||||
out.write(s.accessServers().size());
|
||||
for (String v : s.accessServers()) strU8(out, v);
|
||||
out.write(s.sessionsMode() & 0xff);
|
||||
if (s.sessions().size() > 255) throw new IOException("Слишком много sessions");
|
||||
out.write(s.sessions().size());
|
||||
for (ShineUsersCodec.UserSessionSnapshot session : s.sessions()) {
|
||||
out.write(session.sessionType() & 0xff);
|
||||
out.write(session.sessionVersion() & 0xff);
|
||||
strU8(out, session.sessionName());
|
||||
out.write(Base58Util.decode(session.sessionPubKey()));
|
||||
}
|
||||
out.write(s.trustedCount() & 0xff);
|
||||
// Archive extension к обычному update_user_pda: marker=1 + tx32 + hash32.
|
||||
out.write(1);
|
||||
out.write(archiveTx);
|
||||
out.write(archiveHash);
|
||||
out.write(rootSig);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] buildLastBlockState(UserPdaSnapshot s) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(LAST_BLOCK_STATE_PREFIX);
|
||||
strU8(out, s.login());
|
||||
strU8(out, s.blockchainName());
|
||||
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
|
||||
out.write(unhex(s.lastBlockHash()));
|
||||
u64le(out, s.usedBytes());
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private byte[] buildLegacyMessage(String userPda, byte[] updateData, byte[] rootEd, byte[] bchEd,
|
||||
String recentBlockhash, byte[] clientPub) throws IOException {
|
||||
String economy = SolanaPdaUtil.findProgramAddress(List.of(ECONOMY_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaUsersProgramId());
|
||||
String inflow = SolanaPdaUtil.findProgramAddress(List.of(INFLOW_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaPaymentsProgramId());
|
||||
|
||||
List<Account> accounts = List.of(
|
||||
new Account(Base58Util.encode(clientPub), true, true),
|
||||
new Account(userPda, false, true),
|
||||
new Account(inflow, false, true),
|
||||
new Account(SYSTEM_PROGRAM, false, false),
|
||||
new Account(SYSVAR_INSTRUCTIONS, false, false),
|
||||
new Account(economy, false, false),
|
||||
new Account(COMPUTE_BUDGET_PROGRAM, false, false),
|
||||
new Account(ED25519_PROGRAM, false, false),
|
||||
new Account(cfg.solanaUsersProgramId(), false, false)
|
||||
);
|
||||
Map<String,Integer> ix = new HashMap<>();
|
||||
for (int i=0;i<accounts.size();i++) ix.put(accounts.get(i).pubkey, i);
|
||||
|
||||
List<CompiledInstruction> instructions = new ArrayList<>();
|
||||
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeHeapData(262_144)));
|
||||
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeLimitData(800_000)));
|
||||
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], rootEd));
|
||||
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], bchEd));
|
||||
instructions.add(new CompiledInstruction(ix.get(cfg.solanaUsersProgramId()), new int[]{
|
||||
ix.get(Base58Util.encode(clientPub)), ix.get(userPda), ix.get(SYSTEM_PROGRAM), ix.get(inflow),
|
||||
ix.get(SYSVAR_INSTRUCTIONS), ix.get(economy)
|
||||
}, updateData));
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(1); // numRequiredSignatures
|
||||
out.write(0); // numReadonlySignedAccounts
|
||||
out.write(6); // readonly unsigned: system, sysvar, economy, compute, ed25519, users
|
||||
shortVec(out, accounts.size());
|
||||
for (Account a : accounts) {
|
||||
byte[] pk = Base58Util.decode(a.pubkey);
|
||||
if (pk.length != 32) throw new IOException("Solana pubkey !=32: " + a.pubkey);
|
||||
out.write(pk);
|
||||
}
|
||||
byte[] bh = Base58Util.decode(recentBlockhash);
|
||||
if (bh.length != 32) throw new IOException("recentBlockhash !=32");
|
||||
out.write(bh);
|
||||
shortVec(out, instructions.size());
|
||||
for (CompiledInstruction ci : instructions) {
|
||||
out.write(ci.programIdIndex);
|
||||
shortVec(out, ci.accounts.length);
|
||||
for (int a : ci.accounts) out.write(a);
|
||||
shortVec(out, ci.data.length);
|
||||
out.write(ci.data);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private String getLatestBlockhash() throws Exception {
|
||||
JsonNode result = rpc("getLatestBlockhash", List.of(Map.of("commitment", cfg.solanaCommitment()))).path("result");
|
||||
String blockhash = result.path("value").path("blockhash").asText("");
|
||||
if (blockhash.isBlank()) throw new IOException("Solana getLatestBlockhash не вернул blockhash");
|
||||
return blockhash;
|
||||
}
|
||||
|
||||
private String sendTransaction(byte[] tx) throws Exception {
|
||||
String txB64 = Base64.getEncoder().encodeToString(tx);
|
||||
JsonNode result = rpc("sendTransaction", List.of(txB64, Map.of(
|
||||
"encoding", "base64",
|
||||
"skipPreflight", false,
|
||||
"preflightCommitment", "confirmed",
|
||||
"maxRetries", 5
|
||||
))).path("result");
|
||||
String sig = result.asText("");
|
||||
if (sig.isBlank()) throw new IOException("Solana sendTransaction не вернул signature");
|
||||
return sig;
|
||||
}
|
||||
|
||||
private JsonNode rpc(String method, List<?> params) throws Exception {
|
||||
Map<String,Object> body = new LinkedHashMap<>();
|
||||
body.put("jsonrpc", "2.0"); body.put("id", 1); body.put("method", method); body.put("params", params);
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(cfg.solanaRpcUrl()))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body), StandardCharsets.UTF_8))
|
||||
.build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Solana RPC HTTP " + resp.statusCode() + ": " + safe(resp.body()));
|
||||
JsonNode root = JSON.readTree(resp.body());
|
||||
if (root.hasNonNull("error")) throw new IOException("Solana RPC " + method + " error: " + root.path("error"));
|
||||
return root;
|
||||
}
|
||||
|
||||
private static byte[] serializeTransaction(byte[] signature64, byte[] message) throws IOException {
|
||||
if (signature64.length != 64) throw new IOException("Solana signature !=64");
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
shortVec(out, 1);
|
||||
out.write(signature64);
|
||||
out.write(message);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] buildEd25519Instruction(byte[] signature64, byte[] publicKey32, byte[] message) {
|
||||
if (signature64.length != 64 || publicKey32.length != 32) throw new IllegalArgumentException("Некорректный Ed25519 ix key/signature");
|
||||
int sigOff=16, pkOff=sigOff+64, msgOff=pkOff+32;
|
||||
if (message.length > 0xffff) throw new IllegalArgumentException("Ed25519 message слишком длинный");
|
||||
byte[] data = new byte[msgOff + message.length];
|
||||
data[0]=1; data[1]=0;
|
||||
putU16le(data,2,sigOff); putU16le(data,4,0xffff);
|
||||
putU16le(data,6,pkOff); putU16le(data,8,0xffff);
|
||||
putU16le(data,10,msgOff); putU16le(data,12,message.length); putU16le(data,14,0xffff);
|
||||
System.arraycopy(signature64,0,data,sigOff,64);
|
||||
System.arraycopy(publicKey32,0,data,pkOff,32);
|
||||
System.arraycopy(message,0,data,msgOff,message.length);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static byte[] computeHeapData(int bytes) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(1); u32le(out, Integer.toUnsignedLong(bytes)); return out.toByteArray();
|
||||
}
|
||||
private static byte[] computeLimitData(int units) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(2); u32le(out, Integer.toUnsignedLong(units)); return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void shortVec(ByteArrayOutputStream out, int value) {
|
||||
int rem=value;
|
||||
while (true) {
|
||||
int elem=rem & 0x7f; rem >>>=7;
|
||||
if (rem==0) { out.write(elem); return; }
|
||||
out.write(elem | 0x80);
|
||||
}
|
||||
}
|
||||
private static void strU8(ByteArrayOutputStream out, String value) throws IOException {
|
||||
byte[] b=String.valueOf(value==null?"":value).getBytes(StandardCharsets.UTF_8);
|
||||
if (b.length>255) throw new IOException("Строка >255 bytes"); out.write(b.length); out.write(b);
|
||||
}
|
||||
private static void u32le(ByteArrayOutputStream out, long v) { for(int i=0;i<4;i++) out.write((int)(v >>> (8*i)) & 0xff); }
|
||||
private static void u64le(ByteArrayOutputStream out, long v) { for(int i=0;i<8;i++) out.write((int)(v >>> (8*i)) & 0xff); }
|
||||
private static void putU16le(byte[] d,int o,int v){d[o]=(byte)v;d[o+1]=(byte)(v>>>8);}
|
||||
private static int u16le(byte[] b,int o){return (b[o]&255)|((b[o+1]&255)<<8);}
|
||||
private static byte[] sha256(byte[] bytes) throws Exception { return MessageDigest.getInstance("SHA-256").digest(bytes); }
|
||||
private static String hex(byte[] b){StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||
private static byte[] unhex(String s){String x=String.valueOf(s==null?"":s).trim(); if(x.length()!=64) throw new IllegalArgumentException("hex32 expected");byte[]o=new byte[32];for(int i=0;i<32;i++)o[i]=(byte)Integer.parseInt(x.substring(i*2,i*2+2),16);return o;}
|
||||
private static void require32(byte[] b,String n){if(b==null||b.length!=32)throw new IllegalArgumentException(n+" должен быть 32 bytes");}
|
||||
private static String safe(String s){String x=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return x.length()<=500?x:x.substring(0,500)+"...";}
|
||||
|
||||
private record Current(String userPda, UserPdaSnapshot snapshot) {}
|
||||
private record Account(String pubkey, boolean signer, boolean writable) {}
|
||||
private record CompiledInstruction(int programIdIndex, int[] accounts, byte[] data) {}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class Ans104BundleTest {
|
||||
@Test
|
||||
void writeReadAndFindPreserveExactDataItems() {
|
||||
byte[] a = item("a");
|
||||
byte[] b = item("b");
|
||||
byte[] bundle = Ans104Bundle.write(List.of(a, b));
|
||||
|
||||
List<Ans104Bundle.Entry> entries = Ans104Bundle.read(bundle, 10);
|
||||
assertEquals(2, entries.size());
|
||||
assertArrayEquals(a, entries.get(0).rawDataItem());
|
||||
assertArrayEquals(b, entries.get(1).rawDataItem());
|
||||
|
||||
byte[] bId = new Ans104DataItem(b).id32();
|
||||
assertArrayEquals(b, Ans104Bundle.find(bundle, bId, 10));
|
||||
}
|
||||
|
||||
private static byte[] item(String payload) {
|
||||
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("bundle-" + payload);
|
||||
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
|
||||
byte[] data = payload.getBytes(StandardCharsets.UTF_8);
|
||||
List<Ans104DataItem.Tag> tags = List.of(new Ans104DataItem.Tag("App", "test5590"));
|
||||
byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey);
|
||||
return Ans104DataItem.serializeEd25519(signature, owner, tags, data);
|
||||
}
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Base64;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class ArchiveFileNamesTest {
|
||||
private static final long CREATED_AT = Instant.parse("2026-09-11T00:00:00Z").toEpochMilli();
|
||||
private static final ZoneId UTC = ZoneId.of("UTC");
|
||||
|
||||
@Test
|
||||
void usesTemporaryAndFinalArweaveNames() {
|
||||
byte[] tx = new byte[32];
|
||||
for (int i = 0; i < tx.length; i++) tx[i] = (byte) i;
|
||||
String txText = Base64.getUrlEncoder().withoutPadding().encodeToString(tx);
|
||||
|
||||
assertEquals("archive01.00001.11.09.26.tmp.SHiNE-archive",
|
||||
ArchiveFileNames.pendingFileName("archive01", 1, CREATED_AT, UTC));
|
||||
assertEquals("archive01.00001.11.09.26." + txText + ".SHiNE-archive",
|
||||
ArchiveFileNames.uploadedFileName("archive01", 1, CREATED_AT, UTC, tx));
|
||||
assertEquals("archive01.100000.11.09.26.tmp.SHiNE-archive",
|
||||
ArchiveFileNames.pendingFileName("archive01", 100000, CREATED_AT, UTC));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizesLoginForFileSystemOnly() {
|
||||
assertEquals("archive_user.00002.11.09.26.tmp.SHiNE-archive",
|
||||
ArchiveFileNames.pendingFileName("archive/user", 2, CREATED_AT, UTC));
|
||||
}
|
||||
}
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchivePublishJobChain;
|
||||
import shine.db.entities.BlockEntry;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ShineArchiveWriterTest {
|
||||
@TempDir Path dir;
|
||||
|
||||
@Test
|
||||
void writesOneChunkWithAllRecordsAndOneBacklink() throws Exception {
|
||||
Path file = ArchiveFileNames.pendingFilePath(dir, "serverA", 2, 123456789L, ZoneId.of("UTC"));
|
||||
ArchiveBigBlockRef previous = new ArchiveBigBlockRef(1, new byte[32], new byte[32]);
|
||||
ArchivePublishJobChain chain = new ArchivePublishJobChain(
|
||||
1, "alice-001", 10, 11, new byte[32], new byte[32],
|
||||
1L, new byte[32], 100L, 55L, null, null);
|
||||
|
||||
ShineArchiveWriter.BuildResult result = new ShineArchiveWriter().build(
|
||||
file, "serverA", 2, 123456789L,
|
||||
List.of(previous), List.of(chain),
|
||||
Map.of("alice-001", List.of(block(10, new byte[]{1,2,3}), block(11, new byte[]{4,5}))),
|
||||
new byte[32], 4_000_000_000L);
|
||||
|
||||
assertArrayEquals(result.archiveHash(), ShineArchiveWriter.verifyLocalFile(file));
|
||||
assertEquals("serverA.00002.02.01.70.tmp.SHiNE-archive", file.getFileName().toString());
|
||||
|
||||
try (InputStream raw = java.nio.file.Files.newInputStream(file); DataInputStream in = new DataInputStream(raw)) {
|
||||
assertEquals("SHINE-ARCHIVE", new String(in.readNBytes(13), StandardCharsets.US_ASCII));
|
||||
assertEquals(1, in.readUnsignedByte());
|
||||
assertEquals(0, in.readUnsignedByte());
|
||||
long headerSize = Integer.toUnsignedLong(in.readInt());
|
||||
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(123456789L, in.readLong());
|
||||
int creatorLen = in.readUnsignedByte();
|
||||
assertEquals(ShineArchiveWriter.REFERENCES_FULL, in.readUnsignedByte());
|
||||
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(68, in.readUnsignedShort());
|
||||
assertEquals(0, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals("serverA", new String(in.readNBytes(creatorLen), StandardCharsets.UTF_8));
|
||||
assertEquals(headerSize, 51L + creatorLen);
|
||||
|
||||
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
|
||||
in.skipNBytes(64); // previous hash + txid
|
||||
|
||||
long chunkSize = Integer.toUnsignedLong(in.readInt());
|
||||
int nameLen = in.readUnsignedByte();
|
||||
assertEquals("alice-001", new String(in.readNBytes(nameLen), StandardCharsets.UTF_8));
|
||||
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(3, Integer.toUnsignedLong(in.readInt()));
|
||||
assertArrayEquals(new byte[]{1,2,3}, in.readNBytes(3));
|
||||
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
|
||||
assertArrayEquals(new byte[]{4,5}, in.readNBytes(2));
|
||||
assertEquals(0, Integer.toUnsignedLong(in.readInt())); // previous ref index
|
||||
assertEquals(100, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(55, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(4L + 1 + nameLen + 4 + (4+3) + (4+2) + 12, chunkSize);
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockEntry block(int number, byte[] bytes) {
|
||||
BlockEntry e = new BlockEntry();
|
||||
e.setBlockNumber(number);
|
||||
e.setBlockBytes(bytes);
|
||||
e.setBlockHash(new byte[32]);
|
||||
e.setBlockSignature(new byte[64]);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package blockchain;
|
||||
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Minimal ANS-104 DataItem parser/verifier used by SHiNE.
|
||||
*
|
||||
* SHiNE accepts only generic Ed25519 signature type 2:
|
||||
* uint16le signatureType
|
||||
* signature[64]
|
||||
* owner[32]
|
||||
* target_present[1] + target[32] when present
|
||||
* anchor_present[1] + anchor[32] when present
|
||||
* tags_count uint64le
|
||||
* tags_size uint64le
|
||||
* avro_tags[tags_size]
|
||||
* data[*]
|
||||
*
|
||||
* The signing message matches the deployed arbundles/Turbo DataItem implementation:
|
||||
* deepHash(["dataitem", "1", signatureType, owner, target, anchor, rawAvroTags, data]).
|
||||
*/
|
||||
public final class Ans104DataItem {
|
||||
public static final int SIGNATURE_TYPE_ED25519 = 2;
|
||||
public static final int SIGNATURE_LEN = 64;
|
||||
public static final int OWNER_LEN = 32;
|
||||
public static final int OPTIONAL_FIELD_LEN = 32;
|
||||
public static final int ID_LEN = 32;
|
||||
public static final int MAX_DATA_ITEM_BYTES = 8 * 1024 * 1024;
|
||||
public static final int MAX_TAGS_BYTES = 64 * 1024;
|
||||
public static final long MAX_TAGS_COUNT = 128;
|
||||
|
||||
public record Tag(String name, String value) {}
|
||||
|
||||
private final int signatureType;
|
||||
private final byte[] signature;
|
||||
private final byte[] owner;
|
||||
private final byte[] target;
|
||||
private final byte[] anchor;
|
||||
private final long tagsCount;
|
||||
private final byte[] rawTags;
|
||||
private final List<Tag> tags;
|
||||
private final byte[] data;
|
||||
private final byte[] rawBytes;
|
||||
private final byte[] id;
|
||||
|
||||
public Ans104DataItem(byte[] bytes) {
|
||||
Objects.requireNonNull(bytes, "bytes == null");
|
||||
if (bytes.length < 2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + 1) {
|
||||
throw new IllegalArgumentException("ANS-104 DataItem too short: " + bytes.length);
|
||||
}
|
||||
if (bytes.length > MAX_DATA_ITEM_BYTES) {
|
||||
throw new IllegalArgumentException("ANS-104 DataItem too large: " + bytes.length);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
this.signatureType = Short.toUnsignedInt(bb.getShort());
|
||||
if (signatureType != SIGNATURE_TYPE_ED25519) {
|
||||
throw new IllegalArgumentException("Unsupported ANS-104 signature type: " + signatureType + " (expected 2)");
|
||||
}
|
||||
|
||||
this.signature = readExact(bb, SIGNATURE_LEN, "signature");
|
||||
this.owner = readExact(bb, OWNER_LEN, "owner");
|
||||
this.target = readOptional32(bb, "target");
|
||||
this.anchor = readOptional32(bb, "anchor");
|
||||
|
||||
this.tagsCount = readUnsignedLongAsNonNegative(bb, "tags_count");
|
||||
long tagsSizeLong = readUnsignedLongAsNonNegative(bb, "tags_size");
|
||||
if (tagsCount > MAX_TAGS_COUNT) {
|
||||
throw new IllegalArgumentException("Too many ANS-104 tags: " + tagsCount);
|
||||
}
|
||||
if (tagsSizeLong > MAX_TAGS_BYTES || tagsSizeLong > bb.remaining()) {
|
||||
throw new IllegalArgumentException("Bad ANS-104 tags_size: " + tagsSizeLong + ", remaining=" + bb.remaining());
|
||||
}
|
||||
this.rawTags = readExact(bb, (int) tagsSizeLong, "tags");
|
||||
this.tags = Collections.unmodifiableList(parseAvroTags(rawTags, tagsCount));
|
||||
this.data = new byte[bb.remaining()];
|
||||
bb.get(this.data);
|
||||
this.rawBytes = Arrays.copyOf(bytes, bytes.length);
|
||||
this.id = sha256(signature);
|
||||
}
|
||||
|
||||
public int signatureType() { return signatureType; }
|
||||
public byte[] signature64() { return Arrays.copyOf(signature, signature.length); }
|
||||
public byte[] owner32() { return Arrays.copyOf(owner, owner.length); }
|
||||
public byte[] target() { return Arrays.copyOf(target, target.length); }
|
||||
public byte[] anchor() { return Arrays.copyOf(anchor, anchor.length); }
|
||||
public long tagsCount() { return tagsCount; }
|
||||
public byte[] rawTags() { return Arrays.copyOf(rawTags, rawTags.length); }
|
||||
public List<Tag> tags() { return tags; }
|
||||
public byte[] data() { return Arrays.copyOf(data, data.length); }
|
||||
public byte[] rawBytes() { return Arrays.copyOf(rawBytes, rawBytes.length); }
|
||||
public byte[] id32() { return Arrays.copyOf(id, id.length); }
|
||||
|
||||
public String tagValue(String name) {
|
||||
if (name == null) return null;
|
||||
for (Tag tag : tags) {
|
||||
if (name.equals(tag.name())) return tag.value();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean hasTag(String name, String value) {
|
||||
for (Tag tag : tags) {
|
||||
if (Objects.equals(name, tag.name()) && Objects.equals(value, tag.value())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean verifySignature() {
|
||||
return Ed25519Util.verify(signingMessage(), signature, owner);
|
||||
}
|
||||
|
||||
public boolean ownerEquals(byte[] expectedOwner32) {
|
||||
return expectedOwner32 != null && expectedOwner32.length == OWNER_LEN && Arrays.equals(owner, expectedOwner32);
|
||||
}
|
||||
|
||||
public byte[] signingMessage() {
|
||||
return buildSigningMessage(owner, target, anchor, tags, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the exact ANS-104 signing message for generic Ed25519 DataItems.
|
||||
* Useful for SHiNE clients/tests that must sign a DataItem before serializing it.
|
||||
*/
|
||||
public static byte[] buildSigningMessage(byte[] owner32, List<Tag> tags, byte[] data) {
|
||||
return buildSigningMessage(owner32, new byte[0], new byte[0], tags, data);
|
||||
}
|
||||
|
||||
public static byte[] buildSigningMessage(byte[] owner32, byte[] target, byte[] anchor, List<Tag> tags, byte[] data) {
|
||||
requireLen(owner32, OWNER_LEN, "owner");
|
||||
byte[] safeTarget = target == null ? new byte[0] : Arrays.copyOf(target, target.length);
|
||||
byte[] safeAnchor = anchor == null ? new byte[0] : Arrays.copyOf(anchor, anchor.length);
|
||||
if (!(safeTarget.length == 0 || safeTarget.length == OPTIONAL_FIELD_LEN)) {
|
||||
throw new IllegalArgumentException("target must be empty or 32 bytes");
|
||||
}
|
||||
if (!(safeAnchor.length == 0 || safeAnchor.length == OPTIONAL_FIELD_LEN)) {
|
||||
throw new IllegalArgumentException("anchor must be empty or 32 bytes");
|
||||
}
|
||||
List<Tag> safeTags = validateTags(tags);
|
||||
byte[] safeData = data == null ? new byte[0] : Arrays.copyOf(data, data.length);
|
||||
|
||||
byte[] rawTags = serializeAvroTags(safeTags);
|
||||
return deepHash(List.of(
|
||||
utf8("dataitem"),
|
||||
utf8("1"),
|
||||
utf8(Integer.toString(SIGNATURE_TYPE_ED25519)),
|
||||
Arrays.copyOf(owner32, owner32.length),
|
||||
safeTarget,
|
||||
safeAnchor,
|
||||
rawTags,
|
||||
safeData
|
||||
));
|
||||
}
|
||||
|
||||
/** Serialize one SHiNE-ready ANS-104 DataItem with empty target/anchor. */
|
||||
public static byte[] serializeEd25519(byte[] signature64, byte[] owner32, List<Tag> tags, byte[] data) {
|
||||
requireLen(signature64, SIGNATURE_LEN, "signature");
|
||||
requireLen(owner32, OWNER_LEN, "owner");
|
||||
List<Tag> safeTags = validateTags(tags);
|
||||
byte[] rawTags = serializeAvroTags(safeTags);
|
||||
byte[] safeData = data == null ? new byte[0] : data;
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(
|
||||
2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + rawTags.length + safeData.length);
|
||||
writeU16LE(out, SIGNATURE_TYPE_ED25519);
|
||||
out.writeBytes(signature64);
|
||||
out.writeBytes(owner32);
|
||||
out.write(0); // target absent
|
||||
out.write(0); // anchor absent
|
||||
writeU64LE(out, safeTags.size());
|
||||
writeU64LE(out, rawTags.length);
|
||||
out.writeBytes(rawTags);
|
||||
out.writeBytes(safeData);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] readOptional32(ByteBuffer bb, String name) {
|
||||
if (!bb.hasRemaining()) throw new IllegalArgumentException("Missing " + name + " presence byte");
|
||||
int present = Byte.toUnsignedInt(bb.get());
|
||||
if (present == 0) return new byte[0];
|
||||
if (present != 1) throw new IllegalArgumentException("Bad " + name + " presence byte: " + present);
|
||||
return readExact(bb, OPTIONAL_FIELD_LEN, name);
|
||||
}
|
||||
|
||||
private static byte[] readExact(ByteBuffer bb, int len, String field) {
|
||||
if (len < 0 || bb.remaining() < len) {
|
||||
throw new IllegalArgumentException("ANS-104 " + field + " truncated: need=" + len + " remaining=" + bb.remaining());
|
||||
}
|
||||
byte[] out = new byte[len];
|
||||
bb.get(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static long readUnsignedLongAsNonNegative(ByteBuffer bb, String field) {
|
||||
if (bb.remaining() < 8) throw new IllegalArgumentException("Missing ANS-104 " + field);
|
||||
long v = bb.getLong();
|
||||
if (v < 0) throw new IllegalArgumentException("ANS-104 " + field + " exceeds signed long range");
|
||||
return v;
|
||||
}
|
||||
|
||||
private static List<Tag> validateTags(List<Tag> tags) {
|
||||
List<Tag> safe = tags == null ? List.of() : List.copyOf(tags);
|
||||
if (safe.size() > MAX_TAGS_COUNT) {
|
||||
throw new IllegalArgumentException("Too many ANS-104 tags: " + safe.size());
|
||||
}
|
||||
for (Tag tag : safe) {
|
||||
if (tag == null || tag.name() == null || tag.value() == null || tag.name().isEmpty() || tag.value().isEmpty()) {
|
||||
throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
|
||||
}
|
||||
if (utf8(tag.name()).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
|
||||
if (utf8(tag.value()).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
private static byte[] serializeAvroTags(List<Tag> tags) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
if (!tags.isEmpty()) {
|
||||
writeAvroLong(out, tags.size());
|
||||
for (Tag tag : tags) {
|
||||
writeAvroString(out, tag.name());
|
||||
writeAvroString(out, tag.value());
|
||||
}
|
||||
}
|
||||
writeAvroLong(out, 0); // array terminator; also the complete encoding for an empty array
|
||||
byte[] raw = out.toByteArray();
|
||||
if (raw.length > MAX_TAGS_BYTES) throw new IllegalArgumentException("ANS-104 tags exceed " + MAX_TAGS_BYTES + " bytes");
|
||||
return raw;
|
||||
}
|
||||
|
||||
private static void writeAvroString(ByteArrayOutputStream out, String value) {
|
||||
byte[] bytes = utf8(value);
|
||||
writeAvroLong(out, bytes.length);
|
||||
out.writeBytes(bytes);
|
||||
}
|
||||
|
||||
private static void writeAvroLong(ByteArrayOutputStream out, long value) {
|
||||
long n = (value << 1) ^ (value >> 63);
|
||||
while ((n & ~0x7fL) != 0) {
|
||||
out.write((int) ((n & 0x7f) | 0x80));
|
||||
n >>>= 7;
|
||||
}
|
||||
out.write((int) n);
|
||||
}
|
||||
|
||||
private static void writeU16LE(ByteArrayOutputStream out, int value) {
|
||||
out.write(value & 0xff);
|
||||
out.write((value >>> 8) & 0xff);
|
||||
}
|
||||
|
||||
private static void writeU64LE(ByteArrayOutputStream out, long value) {
|
||||
for (int i = 0; i < 8; i++) out.write((int) ((value >>> (8 * i)) & 0xff));
|
||||
}
|
||||
|
||||
private static void requireLen(byte[] bytes, int expected, String name) {
|
||||
if (bytes == null || bytes.length != expected) {
|
||||
throw new IllegalArgumentException(name + " must be " + expected + " bytes");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Tag> parseAvroTags(byte[] raw, long declaredCount) {
|
||||
AvroCursor c = new AvroCursor(raw);
|
||||
List<Tag> out = new ArrayList<>();
|
||||
while (!c.eof()) {
|
||||
long count = c.readLong();
|
||||
if (count == 0) break;
|
||||
if (count < 0) {
|
||||
count = -count;
|
||||
long blockBytes = c.readLong();
|
||||
if (blockBytes < 0) throw new IllegalArgumentException("Negative Avro tag block size");
|
||||
}
|
||||
if (count > MAX_TAGS_COUNT || out.size() + count > MAX_TAGS_COUNT) {
|
||||
throw new IllegalArgumentException("Too many Avro tags");
|
||||
}
|
||||
for (long i = 0; i < count; i++) {
|
||||
String name = c.readString();
|
||||
String value = c.readString();
|
||||
if (name.isEmpty() || value.isEmpty()) throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
|
||||
if (name.getBytes(StandardCharsets.UTF_8).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
|
||||
if (value.getBytes(StandardCharsets.UTF_8).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
|
||||
out.add(new Tag(name, value));
|
||||
}
|
||||
}
|
||||
if (!c.eof()) {
|
||||
throw new IllegalArgumentException("Trailing bytes in ANS-104 Avro tags");
|
||||
}
|
||||
if (out.size() != declaredCount) {
|
||||
throw new IllegalArgumentException("ANS-104 tags_count mismatch: declared=" + declaredCount + " parsed=" + out.size());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final class AvroCursor {
|
||||
private final byte[] data;
|
||||
private int p;
|
||||
private AvroCursor(byte[] data) { this.data = data == null ? new byte[0] : data; }
|
||||
boolean eof() { return p == data.length; }
|
||||
long readLong() {
|
||||
long raw = 0;
|
||||
int shift = 0;
|
||||
while (true) {
|
||||
if (p >= data.length) throw new IllegalArgumentException("Truncated Avro long");
|
||||
int b = data[p++] & 0xff;
|
||||
raw |= (long) (b & 0x7f) << shift;
|
||||
if ((b & 0x80) == 0) break;
|
||||
shift += 7;
|
||||
if (shift > 63) throw new IllegalArgumentException("Avro long too large");
|
||||
}
|
||||
return (raw >>> 1) ^ -(raw & 1L);
|
||||
}
|
||||
String readString() {
|
||||
long len = readLong();
|
||||
if (len < 0 || len > Integer.MAX_VALUE || p + len > data.length) {
|
||||
throw new IllegalArgumentException("Bad Avro string length: " + len);
|
||||
}
|
||||
String s = new String(data, p, (int) len, StandardCharsets.UTF_8);
|
||||
p += (int) len;
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] deepHash(Object item) {
|
||||
if (item instanceof byte[] bytes) {
|
||||
return deepHashBlob(bytes);
|
||||
}
|
||||
if (item instanceof List<?> items) {
|
||||
byte[] acc = sha384(utf8("list" + items.size()));
|
||||
for (Object child : items) {
|
||||
acc = sha384(concat(acc, deepHash(child)));
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported deepHash item: " + (item == null ? "null" : item.getClass()));
|
||||
}
|
||||
|
||||
private static byte[] deepHashBlob(byte[] bytes) {
|
||||
byte[] tag = utf8("blob" + bytes.length);
|
||||
return sha384(concat(sha384(tag), sha384(bytes)));
|
||||
}
|
||||
|
||||
private static byte[] utf8(String s) { return s.getBytes(StandardCharsets.UTF_8); }
|
||||
private static byte[] sha256(byte[] b) { return digest("SHA-256", b); }
|
||||
private static byte[] sha384(byte[] b) { return digest("SHA-384", b); }
|
||||
private static byte[] digest(String alg, byte[] b) {
|
||||
try { return MessageDigest.getInstance(alg).digest(b); }
|
||||
catch (Exception e) { throw new IllegalStateException(alg + " unavailable", e); }
|
||||
}
|
||||
private static byte[] concat(byte[] a, byte[] b) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(a.length + b.length);
|
||||
out.writeBytes(a); out.writeBytes(b); return out.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -9,363 +9,115 @@ import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* BchBlockEntry — универсальный блок формата SHiNE (Frame v0).
|
||||
* SHiNE user block, Frame v1, carried as the data payload of an ANS-104 DataItem.
|
||||
*
|
||||
* =========================================================================
|
||||
* FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА)
|
||||
* =========================================================================
|
||||
* The constructor accepts the complete serialized ANS-104 DataItem because this is
|
||||
* exactly what is stored in PostgreSQL and exchanged between servers.
|
||||
*
|
||||
* Все числа BigEndian.
|
||||
* SHiNE Frame v1 (all integer fields BigEndian):
|
||||
* [2] frameCode = 0x0001
|
||||
* [32] prevHash32 = SHA-256(Frame v1 bytes of previous SHiNE block)
|
||||
* [4] blockSize = exact Frame v1 byte length
|
||||
* [4] blockNumber
|
||||
* [8] timestamp unix seconds
|
||||
* [2] type
|
||||
* [2] subType
|
||||
* [2] version
|
||||
* [N] bodyBytes
|
||||
*
|
||||
* PREIMAGE (входит в blockSize, подписывается):
|
||||
* [2] frameCode (uint16) код/версия рамки:
|
||||
* - 0x0000 = Frame v0 (текущий)
|
||||
* [32] prevHash32 (bytes) SHA-256(preimage) предыдущего блока (цепочка)
|
||||
* [4] blockSize (int32) размер preimage (в байтах), ВКЛЮЧАЯ frameCode,
|
||||
* НО БЕЗ sigMarker и БЕЗ signature64
|
||||
* [4] blockNumber (int32) глобальный номер блока (>=0)
|
||||
* [8] timestamp (int64) unix seconds
|
||||
* [2] type (uint16) тип сообщения
|
||||
* [2] subType (uint16) подтип сообщения
|
||||
* [2] version (uint16) версия формата сообщения
|
||||
* [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version)
|
||||
*
|
||||
* TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0):
|
||||
* [2] sigMarker (uint16) маркер подписи:
|
||||
* - 0x0100 (256) = далее подпись Ed25519 64 байта
|
||||
* [64] signature64 (bytes) Ed25519 signature над hash32
|
||||
*
|
||||
* hash32 НЕ хранится в блоке.
|
||||
* hash32 вычисляется при парсинге:
|
||||
* preimage = первые blockSize байт
|
||||
* hash32 = SHA-256(preimage)
|
||||
*
|
||||
* Правила MVP-парсера (Frame v0):
|
||||
* - frameCode должен быть строго 0x0000, иначе REJECT.
|
||||
* - sigMarker должен быть строго 0x0100, иначе REJECT.
|
||||
* - подпись обязана присутствовать всегда (sigMarker+signature64).
|
||||
* - НИКАКИХ fallback-веток “если маркер другой, то подписи нет/другой хвост”.
|
||||
*
|
||||
* Важно по безопасности:
|
||||
* - sigMarker в v0 не входит в подписываемые байты → его можно подменить,
|
||||
* поэтому единственная безопасная логика: "если не 0x0100 — reject".
|
||||
* =========================================================================
|
||||
* The user signature is NOT stored in this frame. It is the single Ed25519/Solana
|
||||
* signature of the surrounding ANS-104 DataItem.
|
||||
*/
|
||||
public final class BchBlockEntry {
|
||||
|
||||
public static final int SIGNATURE_LEN = 64;
|
||||
public static final int HASH_LEN = 32;
|
||||
|
||||
public static final int FRAME_CODE_LEN = 2;
|
||||
public static final int SIG_MARKER_LEN = 2;
|
||||
|
||||
/** Frame v0 */
|
||||
public static final int FRAME_CODE_V0 = 0x0000;
|
||||
|
||||
/** sigMarker: 256 = 0x0100 */
|
||||
public static final int SIG_MARKER_ED25519 = 0x0100;
|
||||
|
||||
/**
|
||||
* Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature),
|
||||
* чтобы не уложить сервер по памяти/диску.
|
||||
*/
|
||||
public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Насколько блок может “обгонять” текущее время (защита от кривых часов/вбросов).
|
||||
* Если timestamp больше now + 60 сек — блок считаем неверным.
|
||||
*/
|
||||
public static final int FRAME_CODE_V1 = 0x0001;
|
||||
public static final int MAX_BLOCK_FULL_BYTES = Ans104DataItem.MAX_DATA_ITEM_BYTES;
|
||||
public static final long MAX_FUTURE_SECONDS = 60;
|
||||
public static final int FRAME_HEADER_SIZE = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2;
|
||||
|
||||
/**
|
||||
* Размер фиксированной части PREIMAGE (без bodyBytes).
|
||||
*
|
||||
* PREIMAGE header:
|
||||
* frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8)
|
||||
* + type(2) + subType(2) + version(2)
|
||||
*/
|
||||
public static final int PREIMAGE_HEADER_SIZE =
|
||||
2 // frameCode
|
||||
+ 32 // prevHash32
|
||||
+ 4 // blockSize
|
||||
+ 4 // blockNumber
|
||||
+ 8 // timestamp
|
||||
+ 2 // type
|
||||
+ 2 // subType
|
||||
+ 2; // version
|
||||
|
||||
/** Минимальный полный размер блока (без bodyBytes). */
|
||||
public static final int MIN_FULL_BYTES =
|
||||
PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN;
|
||||
|
||||
// --- HEADER (PREIMAGE) ---
|
||||
public final int frameCode; // uint16 (v0=0)
|
||||
public final byte[] prevHash32; // 32
|
||||
public final int blockSize; // preimage size (включая frameCode)
|
||||
public final int blockNumber; // >=0
|
||||
public final int frameCode;
|
||||
public final byte[] prevHash32;
|
||||
public final int blockSize;
|
||||
public final int blockNumber;
|
||||
public final long timestamp;
|
||||
public final short type;
|
||||
public final short subType;
|
||||
public final short version;
|
||||
|
||||
// --- BODY (PREIMAGE) ---
|
||||
public final byte[] bodyBytes;
|
||||
|
||||
/** Распарсенное тело (создаётся сразу при парсинге блока). */
|
||||
public final BodyRecord body;
|
||||
|
||||
// --- TAIL ---
|
||||
public final int sigMarker; // uint16 (v0: 0x0100)
|
||||
private final byte[] signature64; // 64
|
||||
private final Ans104DataItem dataItem;
|
||||
private final byte[] frameBytes;
|
||||
private final byte[] hash32;
|
||||
|
||||
// --- derived ---
|
||||
private final byte[] hash32; // 32, computed
|
||||
private final byte[] preimage; // blockSize bytes
|
||||
private final byte[] fullBytes; // preimage + sigMarker + signature
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Конструктор из байт ========================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
public BchBlockEntry(byte[] fullBytes) {
|
||||
Objects.requireNonNull(fullBytes, "fullBytes == null");
|
||||
|
||||
if (fullBytes.length < MIN_FULL_BYTES) {
|
||||
throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES);
|
||||
}
|
||||
if (fullBytes.length > MAX_BLOCK_FULL_BYTES) {
|
||||
throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES);
|
||||
/** Parse a complete serialized ANS-104 DataItem containing one SHiNE Frame v1. */
|
||||
public BchBlockEntry(byte[] fullDataItemBytes) {
|
||||
Objects.requireNonNull(fullDataItemBytes, "fullDataItemBytes == null");
|
||||
this.dataItem = new Ans104DataItem(fullDataItemBytes);
|
||||
this.frameBytes = dataItem.data();
|
||||
if (frameBytes.length < FRAME_HEADER_SIZE) {
|
||||
throw new IllegalArgumentException("SHiNE Frame v1 too short: " + frameBytes.length);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
// [2] frameCode
|
||||
ByteBuffer bb = ByteBuffer.wrap(frameBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
this.frameCode = Short.toUnsignedInt(bb.getShort());
|
||||
if (this.frameCode != FRAME_CODE_V0) {
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0
|
||||
));
|
||||
if (frameCode != FRAME_CODE_V1) {
|
||||
throw new IllegalArgumentException(String.format("Bad SHiNE frameCode: 0x%04X (expected 0x%04X)", frameCode, FRAME_CODE_V1));
|
||||
}
|
||||
|
||||
// [32] prevHash32
|
||||
this.prevHash32 = new byte[32];
|
||||
bb.get(this.prevHash32);
|
||||
|
||||
// [4] blockSize
|
||||
bb.get(prevHash32);
|
||||
this.blockSize = bb.getInt();
|
||||
if (blockSize < PREIMAGE_HEADER_SIZE) {
|
||||
throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE);
|
||||
if (blockSize != frameBytes.length || blockSize < FRAME_HEADER_SIZE) {
|
||||
throw new IllegalArgumentException("SHiNE blockSize mismatch: declared=" + blockSize + " actual=" + frameBytes.length);
|
||||
}
|
||||
|
||||
// fullLen must match exactly: blockSize + sigMarker(2) + signature(64)
|
||||
int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
|
||||
if (expectedFullLen != fullBytes.length) {
|
||||
throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize
|
||||
+ " expectedFullLen=" + expectedFullLen
|
||||
+ " fullLen=" + fullBytes.length);
|
||||
}
|
||||
if (expectedFullLen > MAX_BLOCK_FULL_BYTES) {
|
||||
throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES);
|
||||
}
|
||||
|
||||
// [4] blockNumber
|
||||
this.blockNumber = bb.getInt();
|
||||
if (this.blockNumber < 0) {
|
||||
throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber);
|
||||
}
|
||||
|
||||
// [8] timestamp
|
||||
if (blockNumber < 0) throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
|
||||
this.timestamp = bb.getLong();
|
||||
|
||||
// запрет “в будущее” больше чем на 1 минуту
|
||||
long now = Instant.now().getEpochSecond();
|
||||
if (this.timestamp > now + MAX_FUTURE_SECONDS) {
|
||||
throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp
|
||||
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
|
||||
if (timestamp > now + MAX_FUTURE_SECONDS) {
|
||||
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp + " now=" + now);
|
||||
}
|
||||
|
||||
// [2][2][2] type/subType/version
|
||||
this.type = bb.getShort();
|
||||
this.subType = bb.getShort();
|
||||
this.version = bb.getShort();
|
||||
|
||||
// [N] bodyBytes
|
||||
int bodyLen = blockSize - PREIMAGE_HEADER_SIZE;
|
||||
if (bodyLen < 0) {
|
||||
throw new IllegalArgumentException("Invalid body length: " + bodyLen);
|
||||
}
|
||||
this.bodyBytes = new byte[bodyLen];
|
||||
bb.get(this.bodyBytes);
|
||||
|
||||
// TAIL: [2] sigMarker
|
||||
this.sigMarker = Short.toUnsignedInt(bb.getShort());
|
||||
if (this.sigMarker != SIG_MARKER_ED25519) {
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519
|
||||
));
|
||||
this.bodyBytes = new byte[bb.remaining()];
|
||||
bb.get(bodyBytes);
|
||||
this.body = BodyRecordParser.parse(type, subType, version, bodyBytes);
|
||||
this.hash32 = BchCryptoVerifier.sha256(frameBytes);
|
||||
}
|
||||
|
||||
// TAIL: [64] signature64
|
||||
this.signature64 = new byte[SIGNATURE_LEN];
|
||||
bb.get(this.signature64);
|
||||
|
||||
// preimage = первые blockSize байт (включая frameCode)
|
||||
this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize);
|
||||
|
||||
// hash32 = sha256(preimage)
|
||||
this.hash32 = BchCryptoVerifier.sha256(preimage);
|
||||
|
||||
// parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check()
|
||||
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
|
||||
|
||||
this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length);
|
||||
|
||||
if (bb.remaining() != 0) {
|
||||
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Конструктор сборки ============================ */
|
||||
/* ===================================================================== */
|
||||
|
||||
public BchBlockEntry(byte[] prevHash32,
|
||||
int blockNumber,
|
||||
long timestamp,
|
||||
short type,
|
||||
short subType,
|
||||
short version,
|
||||
byte[] bodyBytes,
|
||||
byte[] signature64) {
|
||||
|
||||
Objects.requireNonNull(prevHash32, "prevHash32 == null");
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
Objects.requireNonNull(signature64, "signature64 == null");
|
||||
|
||||
if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32");
|
||||
if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64");
|
||||
|
||||
if (blockNumber < 0) {
|
||||
throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
|
||||
}
|
||||
|
||||
// запрет “в будущее” больше чем на 1 минуту
|
||||
long now = Instant.now().getEpochSecond();
|
||||
if (timestamp > now + MAX_FUTURE_SECONDS) {
|
||||
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp
|
||||
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
|
||||
}
|
||||
|
||||
this.frameCode = FRAME_CODE_V0;
|
||||
this.prevHash32 = Arrays.copyOf(prevHash32, 32);
|
||||
this.blockNumber = blockNumber;
|
||||
this.timestamp = timestamp;
|
||||
this.type = type;
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length);
|
||||
|
||||
// blockSize = размер preimage (включая frameCode)
|
||||
this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length;
|
||||
|
||||
int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
|
||||
if (fullLen > MAX_BLOCK_FULL_BYTES) {
|
||||
throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES);
|
||||
}
|
||||
|
||||
// parse body по header + ОБЯЗАТЕЛЬНЫЙ check()
|
||||
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
|
||||
|
||||
// tail marker фиксирован
|
||||
this.sigMarker = SIG_MARKER_ED25519;
|
||||
this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN);
|
||||
|
||||
// build preimage
|
||||
ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN);
|
||||
pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF));
|
||||
pre.put(this.prevHash32);
|
||||
pre.putInt(this.blockSize);
|
||||
pre.putInt(this.blockNumber);
|
||||
pre.putLong(this.timestamp);
|
||||
pre.putShort(this.type);
|
||||
pre.putShort(this.subType);
|
||||
pre.putShort(this.version);
|
||||
pre.put(this.bodyBytes);
|
||||
|
||||
this.preimage = pre.array();
|
||||
this.hash32 = BchCryptoVerifier.sha256(preimage);
|
||||
|
||||
// build fullBytes: preimage + sigMarker + signature64
|
||||
ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN);
|
||||
full.put(this.preimage);
|
||||
full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF));
|
||||
full.put(this.signature64);
|
||||
this.fullBytes = full.array();
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ============================ Getters ================================= */
|
||||
/* ===================================================================== */
|
||||
|
||||
public byte[] getPreimageBytes() {
|
||||
return Arrays.copyOf(preimage, preimage.length);
|
||||
}
|
||||
|
||||
/** Возвращает подпись Ed25519 (64 байта). */
|
||||
public byte[] getSignature64() {
|
||||
return Arrays.copyOf(signature64, SIGNATURE_LEN);
|
||||
}
|
||||
|
||||
/** Возвращает hash32 = SHA-256(preimage). */
|
||||
public byte[] getHash32() {
|
||||
return Arrays.copyOf(hash32, HASH_LEN);
|
||||
}
|
||||
|
||||
/** Возвращает полный блок: preimage + sigMarker + signature. */
|
||||
public byte[] toBytes() {
|
||||
return Arrays.copyOf(fullBytes, fullBytes.length);
|
||||
}
|
||||
public Ans104DataItem getDataItem() { return dataItem; }
|
||||
public byte[] getFrameBytes() { return Arrays.copyOf(frameBytes, frameBytes.length); }
|
||||
/** Kept as an alias for code/docs that called the old field preimage. */
|
||||
public byte[] getPreimageBytes() { return getFrameBytes(); }
|
||||
public byte[] getSignature64() { return dataItem.signature64(); }
|
||||
public byte[] getOwner32() { return dataItem.owner32(); }
|
||||
public byte[] getDataItemId32() { return dataItem.id32(); }
|
||||
public byte[] getHash32() { return Arrays.copyOf(hash32, hash32.length); }
|
||||
/** Full serialized ANS-104 DataItem. */
|
||||
public byte[] toBytes() { return dataItem.rawBytes(); }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
String timeIso;
|
||||
try {
|
||||
timeIso = Instant.ofEpochSecond(timestamp).toString();
|
||||
} catch (Exception e) {
|
||||
timeIso = "некорректныйTimestamp";
|
||||
}
|
||||
|
||||
return "BchBlockEntry{"
|
||||
+ "FRAME{frameCode=0x" + hex4(frameCode)
|
||||
+ "}, HDR{"
|
||||
+ "blockSize=" + blockSize
|
||||
+ ", blockNumber=" + blockNumber
|
||||
+ ", timestamp=" + timestamp + " (" + timeIso + ")"
|
||||
+ ", type=" + (type & 0xFFFF)
|
||||
+ ", subType=" + (subType & 0xFFFF)
|
||||
+ ", version=" + (version & 0xFFFF)
|
||||
+ ", prevHash32(hex)=" + toHex(prevHash32)
|
||||
+ "}"
|
||||
+ ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}"
|
||||
+ ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}"
|
||||
+ ", DERIVED{hash32(hex)=" + toHex(hash32) + "}"
|
||||
+ "}";
|
||||
}
|
||||
|
||||
private static String hex4(int v) {
|
||||
String s = Integer.toHexString(v & 0xFFFF);
|
||||
while (s.length() < 4) s = "0" + s;
|
||||
return s;
|
||||
return "BchBlockEntry{" +
|
||||
"frameCode=0x" + Integer.toHexString(frameCode) +
|
||||
", blockNumber=" + blockNumber +
|
||||
", timestamp=" + timestamp +
|
||||
", type=" + (type & 0xffff) +
|
||||
", subType=" + (subType & 0xffff) +
|
||||
", version=" + (version & 0xffff) +
|
||||
", bodyLen=" + bodyBytes.length +
|
||||
", dataItemId=" + toHex(getDataItemId32()) +
|
||||
", blockHash=" + toHex(hash32) +
|
||||
'}';
|
||||
}
|
||||
|
||||
private static String toHex(byte[] bytes) {
|
||||
if (bytes == null) return "null";
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
char[] h = "0123456789abcdef".toCharArray();
|
||||
char[] out = new char[bytes.length * 2];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
int vv = bytes[i] & 0xFF;
|
||||
out[i * 2] = HEX[vv >>> 4];
|
||||
out[i * 2 + 1] = HEX[vv & 0x0F];
|
||||
int v = bytes[i] & 0xff;
|
||||
out[i * 2] = h[v >>> 4]; out[i * 2 + 1] = h[v & 15];
|
||||
}
|
||||
return new String(out);
|
||||
}
|
||||
|
||||
+9
-24
@@ -1,42 +1,27 @@
|
||||
package blockchain;
|
||||
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Верификатор SHiNE (Frame v0):
|
||||
*
|
||||
* preimage = первые blockSize байт блока (ВКЛЮЧАЯ frameCode=0x0000),
|
||||
* = всё до TAIL (sigMarker+signature).
|
||||
*
|
||||
* hash32 = SHA-256(preimage)
|
||||
* verify = Ed25519.verify(hash32, signature64, pubKey32)
|
||||
*/
|
||||
/** Cryptographic checks for SHiNE Frame v1 + surrounding ANS-104 DataItem. */
|
||||
public final class BchCryptoVerifier {
|
||||
|
||||
private BchCryptoVerifier() {}
|
||||
|
||||
public static byte[] sha256(byte[] data) {
|
||||
Objects.requireNonNull(data, "data == null");
|
||||
try {
|
||||
MessageDigest d = MessageDigest.getInstance("SHA-256");
|
||||
return d.digest(data);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||
}
|
||||
try { return MessageDigest.getInstance("SHA-256").digest(data); }
|
||||
catch (Exception e) { throw new IllegalStateException("SHA-256 unavailable", e); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the ANS-104 owner is the current blockchain key and that the
|
||||
* ANS-104 Ed25519 signature is valid. The SHiNE block hash itself remains
|
||||
* SHA-256(Frame v1 bytes) and is intentionally independent of Arweave.
|
||||
*/
|
||||
public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) {
|
||||
Objects.requireNonNull(block, "block == null");
|
||||
Objects.requireNonNull(publicKey32, "publicKey32 == null");
|
||||
|
||||
if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32");
|
||||
|
||||
byte[] hash32 = block.getHash32();
|
||||
byte[] sig64 = block.getSignature64();
|
||||
|
||||
return Ed25519Util.verify(hash32, sig64, publicKey32);
|
||||
return block.getDataItem().ownerEquals(publicKey32) && block.getDataItem().verifySignature();
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
package utils.files;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* ===============================================================
|
||||
* FileStoreUtil — утилита работы с файлами в папке data/.
|
||||
*
|
||||
* Теперь поддерживает:
|
||||
* - основной файл блокчейна: <blockchainName>.bch
|
||||
* - временный файл блокчейна: <blockchainName>.tmp_bch
|
||||
* - sidecar-файл проверки записи: <blockchainName>.write_check
|
||||
* - marker-файл записи: <blockchainName>.write_pending
|
||||
*
|
||||
* Важное:
|
||||
* - validateSimpleFileName() запрещает path traversal.
|
||||
* - atomicReplaceBlockchainFile(): пытается сделать ATOMIC_MOVE (если ФС поддерживает),
|
||||
* иначе делает обычный REPLACE_EXISTING move.
|
||||
* ===============================================================
|
||||
*/
|
||||
public final class FileStoreUtil {
|
||||
|
||||
/** Базовая папка для хранения всех файлов (создаётся автоматически). */
|
||||
public static final String DATA_DIR_NAME = "data";
|
||||
|
||||
/** Расширение основного файла блокчейна. */
|
||||
public static final String BLOCKCHAIN_FILE_EXTENSION = ".bch";
|
||||
|
||||
/** Расширение временного файла (старое+новое). */
|
||||
public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch";
|
||||
|
||||
/** Маркер того, что chain сейчас в процессе полного resync. */
|
||||
public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending";
|
||||
|
||||
/** Marker того, что обычный AddBlock находится в опасной фазе записи. */
|
||||
public static final String BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION = ".write_pending";
|
||||
|
||||
/** Sidecar-файл с blockNumber/blockHash для обычного AddBlock. */
|
||||
public static final String BLOCKCHAIN_WRITE_CHECK_EXTENSION = ".write_check";
|
||||
|
||||
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
|
||||
|
||||
private final Path dataDirPath;
|
||||
|
||||
private FileStoreUtil() {
|
||||
this.dataDirPath = Paths.get(DATA_DIR_NAME);
|
||||
ensureDataDirExists();
|
||||
}
|
||||
|
||||
public static FileStoreUtil getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ======================== Базовые операции =========================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
public void newFile(String fileName, byte[] data) {
|
||||
Objects.requireNonNull(data, "data == null");
|
||||
Path target = resolveSafe(fileName);
|
||||
try {
|
||||
Files.write(target, data,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось записать файл: " + target, e);
|
||||
}
|
||||
}
|
||||
|
||||
public void addDataToFile(String fileName, byte[] data) {
|
||||
Objects.requireNonNull(data, "data == null");
|
||||
Path target = resolveSafe(fileName);
|
||||
try {
|
||||
Files.write(target, data,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.WRITE,
|
||||
StandardOpenOption.APPEND);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось дописать файл: " + target, e);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] readAllDataFromFile(String fileName) {
|
||||
Path target = resolveSafe(fileName);
|
||||
if (!Files.exists(target)) {
|
||||
throw new IllegalStateException("Файл не найден: " + target);
|
||||
}
|
||||
try {
|
||||
return Files.readAllBytes(target);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось прочитать файл: " + target, e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean exists(String fileName) {
|
||||
Path target = resolveSafe(fileName);
|
||||
return Files.exists(target);
|
||||
}
|
||||
|
||||
public long size(String fileName) {
|
||||
Path target = resolveSafe(fileName);
|
||||
try {
|
||||
return Files.size(target);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось получить размер файла: " + target, e);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ===================== Блокчейн-файлы по имени ======================= */
|
||||
/* ===================================================================== */
|
||||
|
||||
/** <blockchainName>.bch */
|
||||
public String buildBlockchainFileName(String blockchainName) {
|
||||
validateSimpleFileName(blockchainName);
|
||||
return blockchainName + BLOCKCHAIN_FILE_EXTENSION;
|
||||
}
|
||||
|
||||
/** <blockchainName>.tmp_bch */
|
||||
public String buildBlockchainTmpFileName(String blockchainName) {
|
||||
validateSimpleFileName(blockchainName);
|
||||
return blockchainName + BLOCKCHAIN_TMP_EXTENSION;
|
||||
}
|
||||
|
||||
public Path resolveBlockchainPath(String blockchainName) {
|
||||
return resolveSafe(buildBlockchainFileName(blockchainName));
|
||||
}
|
||||
|
||||
public Path resolveBlockchainTmpPath(String blockchainName) {
|
||||
return resolveSafe(buildBlockchainTmpFileName(blockchainName));
|
||||
}
|
||||
|
||||
public byte[] readBlockchain(String blockchainName) {
|
||||
return readAllDataFromFile(buildBlockchainFileName(blockchainName));
|
||||
}
|
||||
|
||||
public void writeBlockchainTmp(String blockchainName, byte[] data) {
|
||||
newFile(buildBlockchainTmpFileName(blockchainName), data);
|
||||
}
|
||||
|
||||
/** <blockchainName>.write_check */
|
||||
public String buildBlockchainWriteCheckFileName(String blockchainName) {
|
||||
validateSimpleFileName(blockchainName);
|
||||
return blockchainName + BLOCKCHAIN_WRITE_CHECK_EXTENSION;
|
||||
}
|
||||
|
||||
public Path resolveBlockchainWriteCheckPath(String blockchainName) {
|
||||
return resolveSafe(buildBlockchainWriteCheckFileName(blockchainName));
|
||||
}
|
||||
|
||||
public void writeBlockchainWriteCheck(String blockchainName, int blockNumber, String blockHashHex) {
|
||||
StringBuilder sb = new StringBuilder(128);
|
||||
sb.append("blockNumber=").append(blockNumber).append('\n');
|
||||
sb.append("blockHash=").append(blockHashHex == null ? "" : blockHashHex).append('\n');
|
||||
newFile(buildBlockchainWriteCheckFileName(blockchainName), sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** <blockchainName>.write_pending */
|
||||
public String buildBlockchainWritePendingMarkerFileName(String blockchainName) {
|
||||
validateSimpleFileName(blockchainName);
|
||||
return blockchainName + BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION;
|
||||
}
|
||||
|
||||
public Path resolveBlockchainWritePendingMarkerPath(String blockchainName) {
|
||||
return resolveSafe(buildBlockchainWritePendingMarkerFileName(blockchainName));
|
||||
}
|
||||
|
||||
public void writeBlockchainWritePendingMarker(String blockchainName) {
|
||||
newFile(buildBlockchainWritePendingMarkerFileName(blockchainName), new byte[0]);
|
||||
}
|
||||
|
||||
/** <blockchainName>.resync_pending */
|
||||
public String buildBlockchainResyncMarkerFileName(String blockchainName) {
|
||||
validateSimpleFileName(blockchainName);
|
||||
return blockchainName + BLOCKCHAIN_RESYNC_MARKER_EXTENSION;
|
||||
}
|
||||
|
||||
public Path resolveBlockchainResyncMarkerPath(String blockchainName) {
|
||||
return resolveSafe(buildBlockchainResyncMarkerFileName(blockchainName));
|
||||
}
|
||||
|
||||
public void writeBlockchainResyncMarker(String blockchainName, String markerContent) {
|
||||
byte[] data = markerContent == null ? new byte[0] : markerContent.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
newFile(buildBlockchainResyncMarkerFileName(blockchainName), data);
|
||||
}
|
||||
|
||||
public void deleteIfExists(Path path) {
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось удалить файл: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteBlockchainFileIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainPath(blockchainName));
|
||||
}
|
||||
|
||||
public void deleteBlockchainTmpFileIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainTmpPath(blockchainName));
|
||||
}
|
||||
|
||||
public void deleteBlockchainWriteCheckIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName));
|
||||
}
|
||||
|
||||
public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName));
|
||||
}
|
||||
|
||||
public void deleteBlockchainResyncMarkerIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName));
|
||||
}
|
||||
|
||||
public boolean existsBlockchainResyncMarker(String blockchainName) {
|
||||
return exists(buildBlockchainResyncMarkerFileName(blockchainName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Атомарно заменить основной файл блокчейна временным:
|
||||
* <name>.tmp_bch -> <name>.bch
|
||||
*
|
||||
* Стратегия:
|
||||
* 1) Пытаемся Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING)
|
||||
* 2) Если ATOMIC_MOVE не поддерживается — делаем move с REPLACE_EXISTING без атомарности
|
||||
*
|
||||
* Важный нюанс:
|
||||
* - атомарность гарантируется только в пределах одной файловой системы.
|
||||
*/
|
||||
public void atomicReplaceBlockchainFile(String blockchainName) {
|
||||
Path tmp = resolveBlockchainTmpPath(blockchainName);
|
||||
Path main = resolveBlockchainPath(blockchainName);
|
||||
|
||||
if (!Files.exists(tmp)) {
|
||||
throw new IllegalStateException("TMP-файл не найден: " + tmp);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1) Пытаемся атомарный move
|
||||
Files.move(tmp, main,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
// 2) Если ФС не поддерживает атомарный move — делаем обычный replace
|
||||
try {
|
||||
Files.move(tmp, main, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Не удалось заменить файл блокчейна (non-atomic): " + main, ex);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось заменить файл блокчейна (atomic): " + main, e);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ============================ Helpers ================================= */
|
||||
/* ===================================================================== */
|
||||
|
||||
private void ensureDataDirExists() {
|
||||
try {
|
||||
if (!Files.exists(dataDirPath)) {
|
||||
Files.createDirectories(dataDirPath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось создать директорию хранения: " + dataDirPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveSafe(String fileName) {
|
||||
validateSimpleFileName(fileName);
|
||||
return dataDirPath.resolve(fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация "простого имени":
|
||||
* - запрещаем слэши, обратные слэши, ".."
|
||||
* - запрещаем пустоту
|
||||
*
|
||||
* Важно: сюда у нас попадает и blockchainName (как часть имени файла),
|
||||
* поэтому blockchainName должен быть "простым": без путей.
|
||||
*/
|
||||
private void validateSimpleFileName(String fileName) {
|
||||
Objects.requireNonNull(fileName, "fileName == null");
|
||||
if (fileName.isBlank()) {
|
||||
throw new IllegalArgumentException("Имя файла не должно быть пустым");
|
||||
}
|
||||
if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
|
||||
throw new IllegalArgumentException("Недопустимое имя файла: " + fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package blockchain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class Ans104DataItemTest {
|
||||
@Test
|
||||
void ed25519RoundTripWithTestAndChannelTags() {
|
||||
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-test-key");
|
||||
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
|
||||
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
||||
List<Ans104DataItem.Tag> tags = List.of(
|
||||
new Ans104DataItem.Tag("App", "test5590"),
|
||||
new Ans104DataItem.Tag("c", "books")
|
||||
);
|
||||
|
||||
byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
||||
byte[] signature = Ed25519Util.sign(message, privateKey);
|
||||
byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data);
|
||||
|
||||
Ans104DataItem parsed = new Ans104DataItem(raw);
|
||||
assertEquals(2, parsed.signatureType());
|
||||
assertArrayEquals(owner, parsed.owner32());
|
||||
assertArrayEquals(data, parsed.data());
|
||||
assertTrue(parsed.hasTag("App", "test5590"));
|
||||
assertEquals("books", parsed.tagValue("c"));
|
||||
assertTrue(parsed.verifySignature());
|
||||
assertEquals(32, parsed.id32().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mutationBreaksSignature() {
|
||||
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-mutation-key");
|
||||
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
|
||||
byte[] data = "original".getBytes(StandardCharsets.UTF_8);
|
||||
List<Ans104DataItem.Tag> tags = List.of(new Ans104DataItem.Tag("App", "test5590"));
|
||||
byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey);
|
||||
byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data);
|
||||
raw[raw.length - 1] ^= 1;
|
||||
assertFalse(new Ans104DataItem(raw).verifySignature());
|
||||
}
|
||||
@Test
|
||||
void signingMessageMatchesArbundlesTurboCompatibilityVector() {
|
||||
byte[] owner = new byte[32];
|
||||
for (int i = 0; i < owner.length; i++) owner[i] = (byte) i;
|
||||
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
||||
List<Ans104DataItem.Tag> tags = List.of(
|
||||
new Ans104DataItem.Tag("App", "test5590"),
|
||||
new Ans104DataItem.Tag("c", "books")
|
||||
);
|
||||
|
||||
byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
||||
byte[] expected = hex("7e67d0debce103606d697a1e3785130ca20cb89cd8a06f9a65b98b2d2427eaf411fcf7d482da268e44f3ac25c57c3cb9");
|
||||
assertArrayEquals(expected, actual);
|
||||
}
|
||||
|
||||
private static byte[] hex(String value) {
|
||||
if ((value.length() & 1) != 0) throw new IllegalArgumentException("odd hex length");
|
||||
byte[] out = new byte[value.length() / 2];
|
||||
for (int i = 0; i < out.length; i++) {
|
||||
int hi = Character.digit(value.charAt(i * 2), 16);
|
||||
int lo = Character.digit(value.charAt(i * 2 + 1), 16);
|
||||
if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex");
|
||||
out[i] = (byte) ((hi << 4) | lo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# utils.crypto
|
||||
|
||||
Пакет отвечает за криптографию — подписи и хэши блоков.
|
||||
Используется при создании и проверке целостности `.bch`-блоков.
|
||||
Используется при создании и проверке криптографических сущностей SHiNE; пользовательские blockchain-файлы `.bch` больше не используются.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_21 = 21;
|
||||
public static final int SCHEMA_VERSION_22 = 22;
|
||||
public static final int SCHEMA_VERSION_23 = 23;
|
||||
public static final int SCHEMA_VERSION_24 = 24;
|
||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||
@@ -63,6 +64,7 @@ public final class DatabaseInitializer {
|
||||
public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql";
|
||||
public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql";
|
||||
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
|
||||
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -224,6 +226,10 @@ public final class DatabaseInitializer {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V23_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_23;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_24) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_24;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/** Ссылка на уже успешно опубликованный большой архивный блок. */
|
||||
public record ArchiveBigBlockRef(
|
||||
long bigBlockNumber,
|
||||
byte[] archiveHash,
|
||||
byte[] arweaveTxId
|
||||
) {}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/** Последний известный архивный chunk конкретной blockchain на этом сервере. */
|
||||
public record ArchiveBlockchainLocation(
|
||||
String blockchainName,
|
||||
String publisherLogin,
|
||||
String arweaveTxId,
|
||||
byte[] archiveHash,
|
||||
long bigBlockNumber,
|
||||
long chunkOffset,
|
||||
long chunkSize,
|
||||
long sourceLastBlockNumber,
|
||||
long updatedAtMs
|
||||
) {}
|
||||
@@ -1,15 +0,0 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/**
|
||||
* Последнее окончательно опубликованное архивное положение одной SHiNE blockchain.
|
||||
*/
|
||||
public record ArchiveChainCursor(
|
||||
String blockchainName,
|
||||
long lastArchivedSourceBlockNumber,
|
||||
byte[] lastArchivedSourceBlockHash,
|
||||
long lastArchiveBigBlockNumber,
|
||||
byte[] lastArchiveBigBlockHash,
|
||||
long lastChunkOffset,
|
||||
long lastChunkSize,
|
||||
long updatedAtMs
|
||||
) {}
|
||||
@@ -1,16 +0,0 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/** Crash-safe состояние публикации одного большого SHINE-ARCHIVE блока. */
|
||||
public record ArchivePublishJob(
|
||||
long id,
|
||||
long bigBlockNumber,
|
||||
String status,
|
||||
long createdAtMs,
|
||||
String localArchivePath,
|
||||
byte[] archiveHash,
|
||||
byte[] arweaveTxId,
|
||||
Integer arweaveConfirmations,
|
||||
String solanaSignature,
|
||||
String errorText,
|
||||
long updatedAtMs
|
||||
) {}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/** Замороженный диапазон одной blockchain внутри конкретной архивной публикации. */
|
||||
public record ArchivePublishJobChain(
|
||||
long jobId,
|
||||
String blockchainName,
|
||||
long fromSourceBlockNumber,
|
||||
long toSourceBlockNumber,
|
||||
byte[] previousSourceBlockHash,
|
||||
byte[] lastSourceBlockHash,
|
||||
Long previousArchiveBigBlockNumber,
|
||||
byte[] previousArchiveBigBlockHash,
|
||||
Long previousChunkOffset,
|
||||
Long previousChunkSize,
|
||||
Long newChunkOffset,
|
||||
Long newChunkSize
|
||||
) {}
|
||||
@@ -1,14 +0,0 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/**
|
||||
* Текущая archive head trusted publisher-а из локальной таблицы,
|
||||
* которую уже заполнил обычный Solana users sync.
|
||||
*/
|
||||
public record ArchivePublisherHead(
|
||||
String login,
|
||||
String rootKey,
|
||||
String archiveHeadTxId,
|
||||
String archiveHeadHash,
|
||||
boolean archiveImported,
|
||||
String lastImportedArchiveTxId
|
||||
) {}
|
||||
@@ -1,150 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.archive.ArchiveBlockchainLocation;
|
||||
import shine.db.archive.ArchivePublisherHead;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
|
||||
/** БД индекс последних archive chunks и локальное состояние импорта archive head. */
|
||||
public final class ArchiveImportDAO {
|
||||
private static volatile ArchiveImportDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ArchiveImportDAO() {}
|
||||
|
||||
public static ArchiveImportDAO getInstance() {
|
||||
if (instance == null) synchronized (ArchiveImportDAO.class) {
|
||||
if (instance == null) instance = new ArchiveImportDAO();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает только доверенных publisher-ов, для которых обычный Solana users sync
|
||||
* уже увидел archive head, но этот head ещё не был полностью импортирован локально.
|
||||
*/
|
||||
public List<ArchivePublisherHead> listPendingPublisherHeads(Set<String> approvedLogins) throws SQLException {
|
||||
if (approvedLogins == null || approvedLogins.isEmpty()) return List.of();
|
||||
List<ArchivePublisherHead> out = new ArrayList<>();
|
||||
String sql = """
|
||||
SELECT login, root_key, archive_head_tx_id, archive_head_hash,
|
||||
archive_imported, archive_last_imported_tx_id
|
||||
FROM solana_user_pda_current
|
||||
WHERE LOWER(login)=LOWER(?)
|
||||
AND is_server=TRUE
|
||||
AND archive_head_tx_id<>''
|
||||
AND archive_head_hash<>''
|
||||
AND archive_imported=FALSE
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (String login : approvedLogins) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
out.add(new ArchivePublisherHead(
|
||||
rs.getString("login"),
|
||||
rs.getString("root_key"),
|
||||
rs.getString("archive_head_tx_id"),
|
||||
rs.getString("archive_head_hash"),
|
||||
rs.getBoolean("archive_imported"),
|
||||
rs.getString("archive_last_imported_tx_id")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сдвигает локальный курсор импорта внутри FULL archive chain.
|
||||
* Update выполняется только если текущий head всё ещё тот же, с которым работает importer.
|
||||
*/
|
||||
public boolean advanceImportCursor(String publisherLogin, String expectedHeadTxId, String importedTxId) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE solana_user_pda_current
|
||||
SET archive_last_imported_tx_id=?
|
||||
WHERE LOWER(login)=LOWER(?)
|
||||
AND archive_head_tx_id=?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, importedTxId == null ? "" : importedTxId);
|
||||
ps.setString(2, publisherLogin);
|
||||
ps.setString(3, expectedHeadTxId);
|
||||
return ps.executeUpdate() == 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Помечает именно текущий head как полностью импортированный.
|
||||
* Если Solana users sync успел заменить head на более новый, update не произойдёт.
|
||||
*/
|
||||
public boolean markHeadImported(String publisherLogin, String expectedHeadTxId) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE solana_user_pda_current
|
||||
SET archive_imported=TRUE,
|
||||
archive_last_imported_tx_id=archive_head_tx_id
|
||||
WHERE LOWER(login)=LOWER(?)
|
||||
AND archive_head_tx_id=?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, publisherLogin);
|
||||
ps.setString(2, expectedHeadTxId);
|
||||
return ps.executeUpdate() == 1;
|
||||
}
|
||||
}
|
||||
|
||||
public void upsertLocation(ArchiveBlockchainLocation e) throws SQLException {
|
||||
String sql="""
|
||||
INSERT INTO archive_blockchain_location(
|
||||
blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number,
|
||||
chunk_offset,chunk_size,source_last_block_number,updated_at_ms)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(blockchain_name) DO UPDATE SET
|
||||
publisher_login=EXCLUDED.publisher_login,arweave_tx_id=EXCLUDED.arweave_tx_id,
|
||||
archive_hash=EXCLUDED.archive_hash,big_block_number=EXCLUDED.big_block_number,
|
||||
chunk_offset=EXCLUDED.chunk_offset,chunk_size=EXCLUDED.chunk_size,
|
||||
source_last_block_number=EXCLUDED.source_last_block_number,updated_at_ms=EXCLUDED.updated_at_ms
|
||||
WHERE EXCLUDED.source_last_block_number >= archive_blockchain_location.source_last_block_number
|
||||
""";
|
||||
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
|
||||
ps.setString(1,e.blockchainName()); ps.setString(2,e.publisherLogin()); ps.setString(3,e.arweaveTxId());
|
||||
ps.setBytes(4,e.archiveHash()); ps.setLong(5,e.bigBlockNumber()); ps.setLong(6,e.chunkOffset());
|
||||
ps.setLong(7,e.chunkSize()); ps.setLong(8,e.sourceLastBlockNumber()); ps.setLong(9,e.updatedAtMs()); ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public ArchiveBlockchainLocation getLocation(String blockchainName) throws SQLException {
|
||||
String sql="""
|
||||
SELECT blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number,
|
||||
chunk_offset,chunk_size,source_last_block_number,updated_at_ms
|
||||
FROM archive_blockchain_location WHERE LOWER(blockchain_name)=LOWER(?)
|
||||
""";
|
||||
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
|
||||
ps.setString(1,blockchainName);
|
||||
try(ResultSet rs=ps.executeQuery()) {
|
||||
if(!rs.next()) return null;
|
||||
return new ArchiveBlockchainLocation(rs.getString(1),rs.getString(2),rs.getString(3),rs.getBytes(4),
|
||||
rs.getLong(5),rs.getLong(6),rs.getLong(7),rs.getLong(8),rs.getLong(9));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Данные PDA, нужные для создания пустого локального blockchain_state перед первым архивным импортом. */
|
||||
public BlockchainIdentity getBlockchainIdentity(String blockchainName) throws SQLException {
|
||||
String sql="""
|
||||
SELECT login, blockchain_name, blockchain_key, paid_limit_bytes
|
||||
FROM solana_user_pda_current WHERE LOWER(blockchain_name)=LOWER(?) LIMIT 1
|
||||
""";
|
||||
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
|
||||
ps.setString(1,blockchainName);
|
||||
try(ResultSet rs=ps.executeQuery()) {
|
||||
if(!rs.next()) return null;
|
||||
return new BlockchainIdentity(rs.getString(1),rs.getString(2),rs.getString(3),rs.getLong(4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record BlockchainIdentity(String login,String blockchainName,String blockchainKey,long sizeLimit) {}
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchiveChainCursor;
|
||||
import shine.db.archive.ArchivePublishJob;
|
||||
import shine.db.archive.ArchivePublishJobChain;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DAO архивного publisher-а. Все курсоры считаются подтверждёнными только после Solana finalized.
|
||||
*/
|
||||
public final class ArchivePublicationDAO {
|
||||
private static volatile ArchivePublicationDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ArchivePublicationDAO() {}
|
||||
|
||||
public static ArchivePublicationDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ArchivePublicationDAO.class) {
|
||||
if (instance == null) instance = new ArchivePublicationDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ArchiveChainCursor getCursor(String blockchainName) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getCursor(c, blockchainName);
|
||||
}
|
||||
}
|
||||
|
||||
public ArchiveChainCursor getCursor(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
|
||||
last_archive_big_block_number, last_archive_big_block_hash,
|
||||
last_chunk_offset, last_chunk_size, updated_at_ms
|
||||
FROM archive_chain_cursor
|
||||
WHERE blockchain_name = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, blockchainName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapCursor(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long nextBigBlockNumber() throws SQLException {
|
||||
String sql = """
|
||||
SELECT COALESCE(MAX(big_block_number), 0) + 1
|
||||
FROM archive_publish_job
|
||||
WHERE status = 'CURSORS_COMMITTED'
|
||||
""";
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
public List<ArchiveBigBlockRef> listFinalizedBigBlocks() throws SQLException {
|
||||
String sql = """
|
||||
SELECT big_block_number, archive_hash, arweave_tx_id
|
||||
FROM archive_publish_job
|
||||
WHERE status = 'CURSORS_COMMITTED'
|
||||
AND archive_hash IS NOT NULL
|
||||
AND arweave_tx_id IS NOT NULL
|
||||
ORDER BY big_block_number ASC
|
||||
""";
|
||||
List<ArchiveBigBlockRef> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
out.add(new ArchiveBigBlockRef(
|
||||
rs.getLong("big_block_number"),
|
||||
rs.getBytes("archive_hash"),
|
||||
rs.getBytes("arweave_tx_id")
|
||||
));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public ArchivePublishJob findUnfinishedJob() throws SQLException {
|
||||
String sql = """
|
||||
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
|
||||
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
|
||||
error_text, updated_at_ms
|
||||
FROM archive_publish_job
|
||||
WHERE status NOT IN ('CURSORS_COMMITTED', 'FAILED')
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""";
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapJob(rs) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public ArchivePublishJob getJob(long id) throws SQLException {
|
||||
String sql = """
|
||||
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
|
||||
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
|
||||
error_text, updated_at_ms
|
||||
FROM archive_publish_job
|
||||
WHERE id = ?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapJob(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Атомарно создаёт frozen archive job и все его blockchain ranges. */
|
||||
public long createJobWithChains(long bigBlockNumber, long createdAtMs, List<ArchivePublishJobChain> chains) throws SQLException {
|
||||
if (chains == null || chains.isEmpty()) throw new IllegalArgumentException("Archive job без chains не создаётся");
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAuto = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
long jobId;
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
|
||||
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
|
||||
RETURNING id
|
||||
""")) {
|
||||
ps.setLong(1, bigBlockNumber);
|
||||
ps.setLong(2, createdAtMs);
|
||||
ps.setLong(3, createdAtMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
|
||||
jobId = rs.getLong(1);
|
||||
}
|
||||
}
|
||||
String sql = """
|
||||
INSERT INTO archive_publish_job_chain(
|
||||
job_id, blockchain_name, from_source_block_number, to_source_block_number,
|
||||
previous_source_block_hash, last_source_block_hash,
|
||||
previous_archive_big_block_number, previous_archive_big_block_hash,
|
||||
previous_chunk_offset, previous_chunk_size, new_chunk_offset, new_chunk_size
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (ArchivePublishJobChain ch : chains) {
|
||||
bindJobChain(ps, new ArchivePublishJobChain(
|
||||
jobId, ch.blockchainName(), ch.fromSourceBlockNumber(), ch.toSourceBlockNumber(),
|
||||
ch.previousSourceBlockHash(), ch.lastSourceBlockHash(),
|
||||
ch.previousArchiveBigBlockNumber(), ch.previousArchiveBigBlockHash(),
|
||||
ch.previousChunkOffset(), ch.previousChunkSize(), null, null));
|
||||
ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
}
|
||||
c.commit();
|
||||
return jobId;
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException se) throw se;
|
||||
throw new SQLException("Не удалось создать frozen archive job", e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAuto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long createJob(long bigBlockNumber, long createdAtMs) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
|
||||
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
|
||||
RETURNING id
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, bigBlockNumber);
|
||||
ps.setLong(2, createdAtMs);
|
||||
ps.setLong(3, createdAtMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
|
||||
return rs.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void insertJobChain(ArchivePublishJobChain e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO archive_publish_job_chain(
|
||||
job_id, blockchain_name, from_source_block_number, to_source_block_number,
|
||||
previous_source_block_hash, last_source_block_hash,
|
||||
previous_archive_big_block_number, previous_archive_big_block_hash,
|
||||
previous_chunk_offset, previous_chunk_size,
|
||||
new_chunk_offset, new_chunk_size
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindJobChain(ps, e);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public List<ArchivePublishJobChain> listJobChains(long jobId) throws SQLException {
|
||||
String sql = """
|
||||
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
|
||||
previous_source_block_hash, last_source_block_hash,
|
||||
previous_archive_big_block_number, previous_archive_big_block_hash,
|
||||
previous_chunk_offset, previous_chunk_size,
|
||||
new_chunk_offset, new_chunk_size
|
||||
FROM archive_publish_job_chain
|
||||
WHERE job_id = ?
|
||||
ORDER BY blockchain_name ASC
|
||||
""";
|
||||
List<ArchivePublishJobChain> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, jobId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapJobChain(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public void updateNewChunkPosition(long jobId, String blockchainName, long offset, long size) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE archive_publish_job_chain
|
||||
SET new_chunk_offset=?, new_chunk_size=?
|
||||
WHERE job_id=? AND blockchain_name=?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, offset);
|
||||
ps.setLong(2, size);
|
||||
ps.setLong(3, jobId);
|
||||
ps.setString(4, blockchainName);
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("Не найдена archive_publish_job_chain для " + blockchainName);
|
||||
}
|
||||
}
|
||||
|
||||
public void markFileBuilt(long jobId, String localPath, byte[] hash) throws SQLException {
|
||||
updateJob(jobId, "FILE_BUILT", "local_archive_path=? , archive_hash=?", ps -> {
|
||||
ps.setString(1, localPath);
|
||||
ps.setBytes(2, hash);
|
||||
});
|
||||
}
|
||||
|
||||
public void markArweaveUploaded(long jobId, byte[] txId) throws SQLException {
|
||||
updateJob(jobId, "ARWEAVE_UPLOADED", "arweave_tx_id=?", ps -> ps.setBytes(1, txId));
|
||||
}
|
||||
|
||||
/** Обновляет только локальное имя/путь уже собранного archive-файла. */
|
||||
public void updateLocalArchivePath(long jobId, String localPath) throws SQLException {
|
||||
updateJob(jobId, null, "local_archive_path=?", ps -> ps.setString(1, localPath));
|
||||
}
|
||||
|
||||
public void markArweaveConfirmed(long jobId, int confirmations) throws SQLException {
|
||||
updateJob(jobId, "ARWEAVE_CONFIRMED", "arweave_confirmations=?", ps -> ps.setInt(1, confirmations));
|
||||
}
|
||||
|
||||
public void markSolanaSubmitted(long jobId, String signature) throws SQLException {
|
||||
updateJob(jobId, "SOLANA_SUBMITTED", "solana_signature=?", ps -> ps.setString(1, signature));
|
||||
}
|
||||
|
||||
public void markSolanaFinalized(long jobId) throws SQLException {
|
||||
updateJob(jobId, "SOLANA_FINALIZED", null, null);
|
||||
}
|
||||
|
||||
public void markFailed(long jobId, String error) throws SQLException {
|
||||
updateJob(jobId, "FAILED", "error_text=?", ps -> ps.setString(1, error == null ? "" : error));
|
||||
}
|
||||
|
||||
/**
|
||||
* После Solana finalized атомарно переводит курсоры всех blockchain на новый chunk и закрывает job.
|
||||
*/
|
||||
public void commitCursors(long jobId, byte[] bigBlockHash) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAuto = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ArchivePublishJob job = getJob(c, jobId);
|
||||
if (job == null) throw new SQLException("Archive job не найден: " + jobId);
|
||||
List<ArchivePublishJobChain> chains = listJobChains(c, jobId);
|
||||
long now = System.currentTimeMillis();
|
||||
String upsert = """
|
||||
INSERT INTO archive_chain_cursor(
|
||||
blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
|
||||
last_archive_big_block_number, last_archive_big_block_hash,
|
||||
last_chunk_offset, last_chunk_size, updated_at_ms
|
||||
) VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(blockchain_name) DO UPDATE SET
|
||||
last_archived_source_block_number=EXCLUDED.last_archived_source_block_number,
|
||||
last_archived_source_block_hash=EXCLUDED.last_archived_source_block_hash,
|
||||
last_archive_big_block_number=EXCLUDED.last_archive_big_block_number,
|
||||
last_archive_big_block_hash=EXCLUDED.last_archive_big_block_hash,
|
||||
last_chunk_offset=EXCLUDED.last_chunk_offset,
|
||||
last_chunk_size=EXCLUDED.last_chunk_size,
|
||||
updated_at_ms=EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(upsert)) {
|
||||
for (ArchivePublishJobChain ch : chains) {
|
||||
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) {
|
||||
throw new SQLException("Не сохранены координаты нового chunk: " + ch.blockchainName());
|
||||
}
|
||||
ps.setString(1, ch.blockchainName());
|
||||
ps.setLong(2, ch.toSourceBlockNumber());
|
||||
ps.setBytes(3, ch.lastSourceBlockHash());
|
||||
ps.setLong(4, job.bigBlockNumber());
|
||||
ps.setBytes(5, bigBlockHash);
|
||||
ps.setLong(6, ch.newChunkOffset());
|
||||
ps.setLong(7, ch.newChunkSize());
|
||||
ps.setLong(8, now);
|
||||
ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement(
|
||||
"UPDATE archive_publish_job SET status='CURSORS_COMMITTED', updated_at_ms=? WHERE id=?")) {
|
||||
ps.setLong(1, now);
|
||||
ps.setLong(2, jobId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException se) throw se;
|
||||
throw new SQLException("Не удалось commit archive cursors", e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAuto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ArchivePublishJob getJob(Connection c, long id) throws SQLException {
|
||||
String sql = """
|
||||
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
|
||||
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
|
||||
error_text, updated_at_ms
|
||||
FROM archive_publish_job WHERE id=?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? mapJob(rs) : null; }
|
||||
}
|
||||
}
|
||||
|
||||
private List<ArchivePublishJobChain> listJobChains(Connection c, long jobId) throws SQLException {
|
||||
String sql = """
|
||||
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
|
||||
previous_source_block_hash, last_source_block_hash,
|
||||
previous_archive_big_block_number, previous_archive_big_block_hash,
|
||||
previous_chunk_offset, previous_chunk_size,
|
||||
new_chunk_offset, new_chunk_size
|
||||
FROM archive_publish_job_chain
|
||||
WHERE job_id=? ORDER BY blockchain_name ASC
|
||||
""";
|
||||
List<ArchivePublishJobChain> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, jobId);
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapJobChain(rs)); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface Binder { void bind(PreparedStatement ps) throws SQLException; }
|
||||
|
||||
private void updateJob(long jobId, String status, String setFragment, Binder binder) throws SQLException {
|
||||
if (setFragment != null && !setFragment.isBlank()) {
|
||||
try (Connection c = db.getConnection()) {
|
||||
updateJobWithExtra(c, jobId, status, setFragment, binder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
String sql = status == null
|
||||
? "UPDATE archive_publish_job SET updated_at_ms=? WHERE id=?"
|
||||
: "UPDATE archive_publish_job SET status=?, updated_at_ms=? WHERE id=?";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
if (status != null) ps.setString(i++, status);
|
||||
ps.setLong(i++, System.currentTimeMillis());
|
||||
ps.setLong(i, jobId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateJobWithExtra(Connection c, long jobId, String status, String setFragment, Binder binder) throws SQLException {
|
||||
String statusSql = status == null ? "" : ", status=?";
|
||||
String sql = "UPDATE archive_publish_job SET " + setFragment + statusSql + ", updated_at_ms=? WHERE id=?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
binder.bind(ps);
|
||||
int count = countParameters(setFragment);
|
||||
int i = count + 1;
|
||||
if (status != null) ps.setString(i++, status);
|
||||
ps.setLong(i++, System.currentTimeMillis());
|
||||
ps.setLong(i, jobId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static int countParameters(String sqlPart) {
|
||||
int n = 0;
|
||||
for (int i = 0; i < sqlPart.length(); i++) if (sqlPart.charAt(i) == '?') n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
private static void bindJobChain(PreparedStatement ps, ArchivePublishJobChain e) throws SQLException {
|
||||
int i=1;
|
||||
ps.setLong(i++, e.jobId());
|
||||
ps.setString(i++, e.blockchainName());
|
||||
ps.setLong(i++, e.fromSourceBlockNumber());
|
||||
ps.setLong(i++, e.toSourceBlockNumber());
|
||||
ps.setBytes(i++, e.previousSourceBlockHash());
|
||||
ps.setBytes(i++, e.lastSourceBlockHash());
|
||||
setLongNullable(ps, i++, e.previousArchiveBigBlockNumber());
|
||||
ps.setBytes(i++, e.previousArchiveBigBlockHash());
|
||||
setLongNullable(ps, i++, e.previousChunkOffset());
|
||||
setLongNullable(ps, i++, e.previousChunkSize());
|
||||
setLongNullable(ps, i++, e.newChunkOffset());
|
||||
setLongNullable(ps, i, e.newChunkSize());
|
||||
}
|
||||
|
||||
private static ArchiveChainCursor mapCursor(ResultSet rs) throws SQLException {
|
||||
return new ArchiveChainCursor(
|
||||
rs.getString("blockchain_name"),
|
||||
rs.getLong("last_archived_source_block_number"),
|
||||
rs.getBytes("last_archived_source_block_hash"),
|
||||
rs.getLong("last_archive_big_block_number"),
|
||||
rs.getBytes("last_archive_big_block_hash"),
|
||||
rs.getLong("last_chunk_offset"),
|
||||
rs.getLong("last_chunk_size"),
|
||||
rs.getLong("updated_at_ms")
|
||||
);
|
||||
}
|
||||
|
||||
private static ArchivePublishJob mapJob(ResultSet rs) throws SQLException {
|
||||
int conf = rs.getInt("arweave_confirmations");
|
||||
Integer confObj = rs.wasNull() ? null : conf;
|
||||
return new ArchivePublishJob(
|
||||
rs.getLong("id"), rs.getLong("big_block_number"), rs.getString("status"),
|
||||
rs.getLong("created_at_ms"), rs.getString("local_archive_path"),
|
||||
rs.getBytes("archive_hash"), rs.getBytes("arweave_tx_id"), confObj,
|
||||
rs.getString("solana_signature"), rs.getString("error_text"), rs.getLong("updated_at_ms")
|
||||
);
|
||||
}
|
||||
|
||||
private static ArchivePublishJobChain mapJobChain(ResultSet rs) throws SQLException {
|
||||
return new ArchivePublishJobChain(
|
||||
rs.getLong("job_id"), rs.getString("blockchain_name"),
|
||||
rs.getLong("from_source_block_number"), rs.getLong("to_source_block_number"),
|
||||
rs.getBytes("previous_source_block_hash"), rs.getBytes("last_source_block_hash"),
|
||||
getLongNullable(rs, "previous_archive_big_block_number"), rs.getBytes("previous_archive_big_block_hash"),
|
||||
getLongNullable(rs, "previous_chunk_offset"), getLongNullable(rs, "previous_chunk_size"),
|
||||
getLongNullable(rs, "new_chunk_offset"), getLongNullable(rs, "new_chunk_size")
|
||||
);
|
||||
}
|
||||
|
||||
private static Long getLongNullable(ResultSet rs, String name) throws SQLException {
|
||||
long v = rs.getLong(name);
|
||||
return rs.wasNull() ? null : v;
|
||||
}
|
||||
|
||||
private static void setLongNullable(PreparedStatement ps, int idx, Long v) throws SQLException {
|
||||
if (v == null) ps.setNull(idx, Types.BIGINT); else ps.setLong(idx, v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Persistent discovery/import queue for ANS-104 SHiNE blocks found through Arweave. */
|
||||
public final class ArweaveBlockImportDAO {
|
||||
public static final String STATUS_PENDING = "PENDING";
|
||||
public static final String STATUS_REJECTED = "REJECTED";
|
||||
|
||||
public record QueueItem(byte[] dataItemId, String rootTxId, long blockHeight,
|
||||
byte[] rawDataItem, String status, String lastError,
|
||||
long firstSeenAtMs, long updatedAtMs) {}
|
||||
|
||||
private static volatile ArweaveBlockImportDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ArweaveBlockImportDAO() {}
|
||||
|
||||
public static ArweaveBlockImportDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ArweaveBlockImportDAO.class) {
|
||||
if (instance == null) instance = new ArweaveBlockImportDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public long getLastBlockHeight() throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("SELECT last_block_height FROM arweave_block_sync_state WHERE id=1");
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getLong(1) : 0L;
|
||||
}
|
||||
}
|
||||
|
||||
public void setLastBlockHeight(long height, long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
|
||||
VALUES(1,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET last_block_height=EXCLUDED.last_block_height, updated_at_ms=EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, Math.max(0L, height));
|
||||
ps.setLong(2, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */
|
||||
public boolean enqueueIfMissing(byte[] dataItemId, String rootTxId, long blockHeight, byte[] rawDataItem, long nowMs)
|
||||
throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO arweave_block_import_queue(
|
||||
data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||
) VALUES(?,?,?,?,?,'',?,?)
|
||||
ON CONFLICT(data_item_id) DO NOTHING
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setBytes(1, dataItemId);
|
||||
ps.setString(2, rootTxId);
|
||||
ps.setLong(3, blockHeight);
|
||||
ps.setBytes(4, rawDataItem);
|
||||
ps.setString(5, STATUS_PENDING);
|
||||
ps.setLong(6, nowMs);
|
||||
ps.setLong(7, nowMs);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean exists(byte[] dataItemId) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM arweave_block_import_queue WHERE data_item_id=? LIMIT 1")) {
|
||||
ps.setBytes(1, dataItemId);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
|
||||
}
|
||||
}
|
||||
|
||||
public List<QueueItem> listPending(int limit) throws SQLException {
|
||||
int safeLimit = Math.max(1, Math.min(limit, 100_000));
|
||||
String sql = """
|
||||
SELECT data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||
FROM arweave_block_import_queue
|
||||
WHERE status=?
|
||||
ORDER BY block_height ASC, first_seen_at_ms ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<QueueItem> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, STATUS_PENDING);
|
||||
ps.setInt(2, safeLimit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
out.add(new QueueItem(
|
||||
rs.getBytes("data_item_id"), rs.getString("root_tx_id"), rs.getLong("block_height"),
|
||||
rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"),
|
||||
rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms")));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public void setPendingError(byte[] id, String error, long nowMs) throws SQLException {
|
||||
updateStatus(id, STATUS_PENDING, error, nowMs);
|
||||
}
|
||||
|
||||
public void reject(byte[] id, String error, long nowMs) throws SQLException {
|
||||
updateStatus(id, STATUS_REJECTED, error, nowMs);
|
||||
}
|
||||
|
||||
public void delete(byte[] id) throws SQLException {
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
|
||||
"DELETE FROM arweave_block_import_queue WHERE data_item_id=?")) {
|
||||
ps.setBytes(1, id);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStatus(byte[] id, String status, String error, long nowMs) throws SQLException {
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
|
||||
"UPDATE arweave_block_import_queue SET status=?,last_error=?,updated_at_ms=? WHERE data_item_id=?")) {
|
||||
ps.setString(1, status);
|
||||
ps.setString(2, trimError(error));
|
||||
ps.setLong(3, nowMs);
|
||||
ps.setBytes(4, id);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimError(String s) {
|
||||
String v = s == null ? "" : s.replace('\n',' ').replace('\r',' ').trim();
|
||||
return v.length() <= 500 ? v : v.substring(0,500);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -23,7 +23,7 @@ import java.sql.SQLException;
|
||||
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
|
||||
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
|
||||
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
|
||||
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
|
||||
* - пользовательские blockchain-файлы больше не существуют; cleanup полностью DB-only.
|
||||
* SQL-транзакция и файловая система не коммитятся атомарно вместе;
|
||||
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
|
||||
* отдельным recovery/resync-слоем после успешного commit.
|
||||
@@ -71,7 +71,7 @@ public final class BlockchainResyncCleanupDAO {
|
||||
* - если blockchain_state уже отсутствует, login берём из blockchainName.
|
||||
*
|
||||
* Отдельно важно:
|
||||
* - здесь НЕТ удаления .bch/.tmp_bch;
|
||||
* - filesystem blockchain storage отсутствует;
|
||||
* - здесь НЕТ повторной загрузки цепочки;
|
||||
* - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow.
|
||||
*/
|
||||
|
||||
@@ -9,286 +9,147 @@ import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DAO для таблицы blocks (новый формат).
|
||||
*
|
||||
* Правило:
|
||||
* - методы с Connection НЕ закрывают соединение
|
||||
* - методы без Connection сами открывают и закрывают соединение
|
||||
*
|
||||
* Ключ:
|
||||
* - (bch_name, block_number) — уникальная пара в рамках общей БД сервера.
|
||||
*/
|
||||
/** DAO for SHiNE Frame v1 blocks. block_bytes contains the complete ANS-104 DataItem. */
|
||||
public final class BlocksDAO {
|
||||
|
||||
private static volatile BlocksDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class);
|
||||
|
||||
private BlocksDAO() { }
|
||||
|
||||
public static BlocksDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (BlocksDAO.class) {
|
||||
if (instance == null) instance = new BlocksDAO();
|
||||
}
|
||||
}
|
||||
if (instance == null) synchronized (BlocksDAO.class) { if (instance == null) instance = new BlocksDAO(); }
|
||||
return instance;
|
||||
}
|
||||
|
||||
// -------------------- INSERT --------------------
|
||||
|
||||
/** Вставка с внешним соединением. Соединение НЕ закрывает. */
|
||||
public void insert(Connection c, BlockEntry e) throws SQLException {
|
||||
log.info("DBG BlockEntry: type={} sub={} lineCode={} prevLineNumber={} thisLineNumber={} prevLineHashLen={}",
|
||||
e.getMsgType(), e.getMsgSubType(),
|
||||
e.getLineCode(), e.getPrevLineNumber(), e.getThisLineNumber(),
|
||||
e.getPrevLineHash() == null ? null : e.getPrevLineHash().length
|
||||
);
|
||||
|
||||
String sql = """
|
||||
INSERT INTO blocks (
|
||||
login,
|
||||
bch_name,
|
||||
block_number,
|
||||
msg_type,
|
||||
msg_sub_type,
|
||||
block_bytes,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash,
|
||||
block_hash,
|
||||
block_signature,
|
||||
edited_by_block_number,
|
||||
line_code,
|
||||
prev_line_number,
|
||||
prev_line_hash,
|
||||
this_line_number
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||
to_login,to_bch_name,to_block_number,to_block_hash,
|
||||
block_hash,block_signature,data_item_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
|
||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
|
||||
ps.setString(i++, e.getLogin());
|
||||
ps.setString(i++, e.getBchName());
|
||||
ps.setInt(i++, e.getBlockNumber());
|
||||
|
||||
ps.setInt(i++, e.getMsgType());
|
||||
ps.setInt(i++, e.getMsgSubType());
|
||||
|
||||
ps.setBytes(i++, e.getBlockBytes());
|
||||
|
||||
if (e.getToLogin() != null) ps.setString(i++, e.getToLogin());
|
||||
else ps.setNull(i++, Types.VARCHAR);
|
||||
|
||||
if (e.getToBchName() != null) ps.setString(i++, e.getToBchName());
|
||||
else ps.setNull(i++, Types.VARCHAR);
|
||||
|
||||
if (e.getToBlockNumber() != null) ps.setInt(i++, e.getToBlockNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash());
|
||||
else ps.setNull(i++, Types.BINARY);
|
||||
|
||||
setNullableString(ps, i++, e.getToLogin());
|
||||
setNullableString(ps, i++, e.getToBchName());
|
||||
setNullableInt(ps, i++, e.getToBlockNumber());
|
||||
setNullableBytes(ps, i++, e.getToBlockHash());
|
||||
ps.setBytes(i++, e.getBlockHash());
|
||||
ps.setBytes(i++, e.getBlockSignature());
|
||||
|
||||
if (e.getEditedByBlockNumber() != null) ps.setInt(i++, e.getEditedByBlockNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
// NEW: line_code
|
||||
if (e.getLineCode() != null) ps.setInt(i++, e.getLineCode());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
if (e.getPrevLineNumber() != null) ps.setInt(i++, e.getPrevLineNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash());
|
||||
else ps.setNull(i++, Types.BINARY);
|
||||
|
||||
if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
ps.setBytes(i++, e.getDataItemId());
|
||||
ps.setBoolean(i++, e.isArweavePublishPending());
|
||||
if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs());
|
||||
setNullableString(ps, i++, e.getArweaveRootTxId());
|
||||
setNullableInt(ps, i++, e.getEditedByBlockNumber());
|
||||
setNullableInt(ps, i++, e.getLineCode());
|
||||
setNullableInt(ps, i++, e.getPrevLineNumber());
|
||||
setNullableBytes(ps, i++, e.getPrevLineHash());
|
||||
setNullableInt(ps, i++, e.getThisLineNumber());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Вставка без внешнего соединения. Сам открывает/закрывает. */
|
||||
public void insert(BlockEntry e) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
insert(c, e);
|
||||
public void insert(BlockEntry e) throws SQLException { try (Connection c = db.getConnection()) { insert(c, e); } }
|
||||
|
||||
public boolean existsByDataItemId(byte[] dataItemId) throws SQLException {
|
||||
if (dataItemId == null || dataItemId.length != 32) return false;
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM blocks WHERE data_item_id=? LIMIT 1")) {
|
||||
ps.setBytes(1, dataItemId);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT: HASH BY NUMBER --------------------
|
||||
|
||||
/** Получить block_hash по (bch_name, block_number). Нужен для линейной проверки. */
|
||||
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
||||
public List<BlockEntry> listPendingArweave(int limit) throws SQLException {
|
||||
int safeLimit = Math.max(1, Math.min(limit, 100_000));
|
||||
String sql = """
|
||||
SELECT block_hash
|
||||
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
|
||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name = ? AND block_number = ?
|
||||
LIMIT 1
|
||||
WHERE arweave_publish_pending = TRUE
|
||||
ORDER BY bch_name ASC, block_number ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bchName);
|
||||
ps.setInt(2, blockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return rs.getBytes("block_hash");
|
||||
}
|
||||
List<BlockEntry> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setInt(1, safeLimit);
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException {
|
||||
public void markArweavePublished(List<byte[]> dataItemIds, String rootTxId, long publishedAtMs) throws SQLException {
|
||||
if (dataItemIds == null || dataItemIds.isEmpty()) return;
|
||||
String sql = """
|
||||
UPDATE blocks
|
||||
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?, arweave_root_tx_id=?
|
||||
WHERE data_item_id=?
|
||||
""";
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getHashByNumber(c, bchName, blockNumber);
|
||||
c.setAutoCommit(false);
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (byte[] id : dataItemIds) {
|
||||
ps.setLong(1, publishedAtMs); ps.setString(2, rootTxId); ps.setBytes(3, id); ps.addBatch();
|
||||
}
|
||||
ps.executeBatch(); c.commit();
|
||||
} catch (Exception e) { c.rollback(); throw e; }
|
||||
finally { c.setAutoCommit(true); }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT: FULL ENTRY --------------------
|
||||
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT block_hash FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1")) {
|
||||
ps.setString(1,bchName); ps.setInt(2,blockNumber);
|
||||
try (ResultSet rs=ps.executeQuery()) { return rs.next()?rs.getBytes(1):null; }
|
||||
}
|
||||
}
|
||||
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { try(Connection c=db.getConnection()){return getHashByNumber(c,bchName,blockNumber);} }
|
||||
|
||||
public BlockEntry getByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
bch_name,
|
||||
block_number,
|
||||
msg_type,
|
||||
msg_sub_type,
|
||||
block_bytes,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash,
|
||||
block_hash,
|
||||
block_signature,
|
||||
edited_by_block_number,
|
||||
line_code,
|
||||
prev_line_number,
|
||||
prev_line_hash,
|
||||
this_line_number
|
||||
String sql = baseSelect()+" WHERE bch_name=? AND block_number=? LIMIT 1";
|
||||
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,bchName); ps.setInt(2,blockNumber); try(ResultSet rs=ps.executeQuery()){return rs.next()?mapRow(rs):null;} }
|
||||
}
|
||||
public BlockEntry getByNumber(String bchName,int blockNumber)throws SQLException{try(Connection c=db.getConnection()){return getByNumber(c,bchName,blockNumber);} }
|
||||
|
||||
public List<BlockEntry> listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} }
|
||||
public List<BlockEntry> listRangeByNumber(Connection c,String bchName,int from,int to)throws SQLException{
|
||||
String sql=baseSelect()+" WHERE bch_name=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC";
|
||||
List<BlockEntry> out=new ArrayList<>();
|
||||
try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,bchName);ps.setInt(2,from);ps.setInt(3,to);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(mapRow(rs));}}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String baseSelect(){return """
|
||||
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
|
||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name = ? AND block_number = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bchName);
|
||||
ps.setInt(2, blockNumber);
|
||||
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BlockEntry getByNumber(String bchName, int blockNumber) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByNumber(c, bchName, blockNumber);
|
||||
}
|
||||
}
|
||||
|
||||
public List<BlockEntry> listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive);
|
||||
}
|
||||
}
|
||||
|
||||
public List<BlockEntry> listRangeByNumber(Connection c, String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
bch_name,
|
||||
block_number,
|
||||
msg_type,
|
||||
msg_sub_type,
|
||||
block_bytes,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash,
|
||||
block_hash,
|
||||
block_signature,
|
||||
edited_by_block_number,
|
||||
line_code,
|
||||
prev_line_number,
|
||||
prev_line_hash,
|
||||
this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name = ?
|
||||
AND block_number >= ?
|
||||
AND block_number <= ?
|
||||
ORDER BY block_number ASC
|
||||
""";
|
||||
|
||||
List<BlockEntry> result = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bchName);
|
||||
ps.setInt(2, fromBlockNumberInclusive);
|
||||
ps.setInt(3, toBlockNumberInclusive);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------- INTERNAL --------------------
|
||||
|
||||
private BlockEntry mapRow(ResultSet rs) throws SQLException {
|
||||
BlockEntry e = new BlockEntry();
|
||||
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setBchName(rs.getString("bch_name"));
|
||||
e.setBlockNumber(rs.getInt("block_number"));
|
||||
|
||||
e.setMsgType(rs.getInt("msg_type"));
|
||||
e.setMsgSubType(rs.getInt("msg_sub_type"));
|
||||
|
||||
e.setBlockBytes(rs.getBytes("block_bytes"));
|
||||
|
||||
String toLogin = rs.getString("to_login");
|
||||
if (rs.wasNull()) toLogin = null;
|
||||
e.setToLogin(toLogin);
|
||||
|
||||
String toBchName = rs.getString("to_bch_name");
|
||||
if (rs.wasNull()) toBchName = null;
|
||||
e.setToBchName(toBchName);
|
||||
|
||||
Integer toBlockNumber = (Integer) rs.getObject("to_block_number");
|
||||
e.setToBlockNumber(toBlockNumber);
|
||||
|
||||
byte[] toHash = rs.getBytes("to_block_hash");
|
||||
if (rs.wasNull()) toHash = null;
|
||||
e.setToBlockHash(toHash);
|
||||
|
||||
e.setBlockHash(rs.getBytes("block_hash"));
|
||||
e.setBlockSignature(rs.getBytes("block_signature"));
|
||||
|
||||
Integer editedBy = (Integer) rs.getObject("edited_by_block_number");
|
||||
e.setEditedByBlockNumber(editedBy);
|
||||
|
||||
// NEW: line_code
|
||||
Integer lineCode = (Integer) rs.getObject("line_code");
|
||||
e.setLineCode(lineCode);
|
||||
|
||||
Integer prevLn = (Integer) rs.getObject("prev_line_number");
|
||||
e.setPrevLineNumber(prevLn);
|
||||
|
||||
byte[] prevLh = rs.getBytes("prev_line_hash");
|
||||
if (rs.wasNull()) prevLh = null;
|
||||
e.setPrevLineHash(prevLh);
|
||||
|
||||
Integer thisLn = (Integer) rs.getObject("this_line_number");
|
||||
e.setThisLineNumber(thisLn);
|
||||
""";}
|
||||
|
||||
private BlockEntry mapRow(ResultSet rs)throws SQLException{
|
||||
BlockEntry e=new BlockEntry();
|
||||
e.setLogin(rs.getString("login")); e.setBchName(rs.getString("bch_name")); e.setBlockNumber(rs.getInt("block_number"));
|
||||
e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes"));
|
||||
e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash"));
|
||||
e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id"));
|
||||
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms")); e.setArweaveRootTxId(rs.getString("arweave_root_tx_id"));
|
||||
e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number"));
|
||||
return e;
|
||||
}
|
||||
|
||||
private static void setNullableString(PreparedStatement ps,int i,String v)throws SQLException{if(v==null)ps.setNull(i,Types.VARCHAR);else ps.setString(i,v);}
|
||||
private static void setNullableInt(PreparedStatement ps,int i,Integer v)throws SQLException{if(v==null)ps.setNull(i,Types.INTEGER);else ps.setInt(i,v);}
|
||||
private static void setNullableBytes(PreparedStatement ps,int i,byte[] v)throws SQLException{if(v==null)ps.setNull(i,Types.BINARY);else ps.setBytes(i,v);}
|
||||
}
|
||||
|
||||
@@ -120,4 +120,15 @@ public final class ChannelNameStateDAO {
|
||||
insert(c, entry);
|
||||
}
|
||||
}
|
||||
public String getSlugByOwnerAndRoot(String ownerBchName, int rootBlockNumber) throws SQLException {
|
||||
String sql = "SELECT slug FROM channel_names_state WHERE owner_bch_name=? AND channel_root_block_number=? LIMIT 1";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setInt(2, rootBlockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString(1) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем.
|
||||
@@ -60,6 +61,23 @@ public final class SolanaUserPdaCurrentDAO {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a current user by the 32-byte Ed25519 blockchain key used as ANS-104 owner. */
|
||||
public SolanaUserPdaCurrentEntry getByBlockchainKey(byte[] owner32) throws SQLException {
|
||||
if (owner32 == null || owner32.length != 32) return null;
|
||||
String wanted = Base64.getEncoder().encodeToString(owner32);
|
||||
String sql = """
|
||||
SELECT login, blockchain_name, blockchain_key, paid_limit_bytes
|
||||
FROM solana_user_pda_current
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql); ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
SolanaUserPdaCurrentEntry e = mapRow(rs);
|
||||
if (wanted.equals(e.getBlockchainKey())) return e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException {
|
||||
SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry();
|
||||
entry.setLogin(rs.getString("login"));
|
||||
|
||||
@@ -37,6 +37,10 @@ public class BlockEntry {
|
||||
|
||||
private byte[] blockHash;
|
||||
private byte[] blockSignature;
|
||||
private byte[] dataItemId;
|
||||
private boolean arweavePublishPending;
|
||||
private Long arweavePublishedAtMs;
|
||||
private String arweaveRootTxId;
|
||||
|
||||
private Integer editedByBlockNumber;
|
||||
|
||||
@@ -85,6 +89,15 @@ public class BlockEntry {
|
||||
public byte[] getBlockSignature() { return blockSignature; }
|
||||
public void setBlockSignature(byte[] blockSignature) { this.blockSignature = blockSignature; }
|
||||
|
||||
public byte[] getDataItemId() { return dataItemId; }
|
||||
public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; }
|
||||
public boolean isArweavePublishPending() { return arweavePublishPending; }
|
||||
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
|
||||
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
|
||||
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
|
||||
public String getArweaveRootTxId() { return arweaveRootTxId; }
|
||||
public void setArweaveRootTxId(String arweaveRootTxId) { this.arweaveRootTxId = arweaveRootTxId; }
|
||||
|
||||
public Integer getEditedByBlockNumber() { return editedByBlockNumber; }
|
||||
public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; }
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Frame v1 intentionally has no backward compatibility with the old signed block format.
|
||||
-- Refuse to migrate a populated blockchain database: test/dev data must be reset first.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM blocks LIMIT 1)
|
||||
OR EXISTS (SELECT 1 FROM blockchain_state WHERE last_block_number >= 0 LIMIT 1) THEN
|
||||
RAISE EXCEPTION 'migration_v24 requires an empty blockchain database; reset test/dev blockchain data before upgrading';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Старый SHINE-ARCHIVE v1 больше не используется.
|
||||
DROP TABLE IF EXISTS archive_publish_job_chain CASCADE;
|
||||
DROP TABLE IF EXISTS archive_publish_job CASCADE;
|
||||
DROP TABLE IF EXISTS archive_chain_cursor CASCADE;
|
||||
DROP TABLE IF EXISTS archive_blockchain_location CASCADE;
|
||||
|
||||
-- SHiNE Frame v1 / ANS-104 DataItem storage. No legacy .bch files are required.
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS data_item_id BYTEA;
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS arweave_published_at_ms BIGINT;
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS arweave_root_tx_id TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
|
||||
ON blocks(data_item_id)
|
||||
WHERE data_item_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
|
||||
ON blocks(block_number)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
|
||||
id SMALLINT PRIMARY KEY CHECK (id = 1),
|
||||
last_block_height BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
|
||||
VALUES(1,0,0) ON CONFLICT(id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
|
||||
data_item_id BYTEA PRIMARY KEY,
|
||||
root_tx_id TEXT NOT NULL,
|
||||
block_height BIGINT NOT NULL,
|
||||
raw_data_item BYTEA NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
first_seen_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
|
||||
ON arweave_block_import_queue(block_height,first_seen_at_ms)
|
||||
WHERE status='PENDING';
|
||||
|
||||
UPDATE db_schema_version SET schema_version=24, updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id=1;
|
||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
||||
);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 20, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
VALUES (1, 24, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
@@ -291,12 +291,6 @@ AFTER TRUNCATE ON solana_user_pda_current
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
|
||||
CREATE TRIGGER trg_user_stats_state_ai
|
||||
AFTER INSERT ON solana_user_pda_current
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_user_stats_state_ai();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS solana_user_pda_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tx_signature TEXT NOT NULL,
|
||||
@@ -503,6 +497,10 @@ CREATE TABLE IF NOT EXISTS blocks (
|
||||
to_block_hash BYTEA,
|
||||
block_hash BYTEA NOT NULL,
|
||||
block_signature BYTEA NOT NULL,
|
||||
data_item_id BYTEA NOT NULL,
|
||||
arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
arweave_published_at_ms BIGINT,
|
||||
arweave_root_tx_id TEXT,
|
||||
edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0),
|
||||
line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0),
|
||||
prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0),
|
||||
@@ -514,12 +512,41 @@ CREATE TABLE IF NOT EXISTS blocks (
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number
|
||||
ON blocks (bch_name, block_number);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
|
||||
ON blocks(data_item_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
|
||||
ON blocks(block_number)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_to_target
|
||||
ON blocks (to_login, to_bch_name, to_block_number);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_line
|
||||
ON blocks (bch_name, line_code, this_line_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
|
||||
id SMALLINT PRIMARY KEY CHECK (id = 1),
|
||||
last_block_height BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO arweave_block_sync_state(id, last_block_height, updated_at_ms)
|
||||
VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
|
||||
data_item_id BYTEA PRIMARY KEY,
|
||||
root_tx_id TEXT NOT NULL,
|
||||
block_height BIGINT NOT NULL,
|
||||
raw_data_item BYTEA NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
first_seen_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
|
||||
ON arweave_block_import_queue(block_height,first_seen_at_ms)
|
||||
WHERE status='PENDING';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connections_state (
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
rel_type INTEGER NOT NULL,
|
||||
@@ -1660,6 +1687,12 @@ AFTER INSERT ON blocks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_blocks_edit_apply_ai();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
|
||||
CREATE TRIGGER trg_user_stats_state_ai
|
||||
AFTER INSERT ON solana_user_pda_current
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_user_stats_state_ai();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
|
||||
CREATE TRIGGER trg_channel_names_state_stats_ai
|
||||
AFTER INSERT ON channel_names_state
|
||||
@@ -2028,52 +2061,6 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||
PRIMARY KEY (owner_login, category)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_chain_cursor (
|
||||
blockchain_name TEXT PRIMARY KEY,
|
||||
last_archived_source_block_number BIGINT NOT NULL,
|
||||
last_archived_source_block_hash BYTEA NOT NULL,
|
||||
last_archive_big_block_number BIGINT NOT NULL,
|
||||
last_archive_big_block_hash BYTEA NOT NULL,
|
||||
last_chunk_offset BIGINT NOT NULL,
|
||||
last_chunk_size BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_publish_job (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
big_block_number BIGINT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
local_archive_path TEXT,
|
||||
archive_hash BYTEA,
|
||||
arweave_tx_id BYTEA,
|
||||
arweave_confirmations INTEGER,
|
||||
solana_signature TEXT,
|
||||
error_text TEXT,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status
|
||||
ON archive_publish_job(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number
|
||||
ON archive_publish_job(big_block_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_publish_job_chain (
|
||||
job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE,
|
||||
blockchain_name TEXT NOT NULL,
|
||||
from_source_block_number BIGINT NOT NULL,
|
||||
to_source_block_number BIGINT NOT NULL,
|
||||
previous_source_block_hash BYTEA NOT NULL,
|
||||
last_source_block_hash BYTEA NOT NULL,
|
||||
previous_archive_big_block_number BIGINT,
|
||||
previous_archive_big_block_hash BYTEA,
|
||||
previous_chunk_offset BIGINT,
|
||||
previous_chunk_size BIGINT,
|
||||
new_chunk_offset BIGINT,
|
||||
new_chunk_size BIGINT,
|
||||
PRIMARY KEY (job_id, blockchain_name)
|
||||
);
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE solana_user_pda_current
|
||||
@@ -2093,16 +2080,8 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
|
||||
ON solana_user_pda_current(is_server, archive_imported)
|
||||
WHERE archive_head_tx_id <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_blockchain_location (
|
||||
blockchain_name TEXT PRIMARY KEY, publisher_login TEXT NOT NULL, arweave_tx_id TEXT NOT NULL,
|
||||
archive_hash BYTEA NOT NULL, big_block_number BIGINT NOT NULL, chunk_offset BIGINT NOT NULL,
|
||||
chunk_size BIGINT NOT NULL, source_last_block_number BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher ON archive_blockchain_location(publisher_login);
|
||||
|
||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||
VALUES(1,23,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-4
@@ -42,10 +42,8 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetArchiveBlockchainLocation_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetArchiveBlockchainLocation_Request;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||||
@@ -193,7 +191,6 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
||||
Map.entry("GetArchiveBlockchainLocation", new Net_GetArchiveBlockchainLocation_Handler()),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||
@@ -286,7 +283,6 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||||
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
||||
Map.entry("GetArchiveBlockchainLocation", Net_GetArchiveBlockchainLocation_Request.class),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
||||
|
||||
+63
-9
@@ -103,6 +103,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
req.getBlockNumber(), // старое поле, пока оставляем
|
||||
req.getPrevBlockHash(), // старое поле, пока оставляем
|
||||
req.getBlockBytesB64(),
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
@@ -163,6 +164,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "limit_exceeded" -> "Превышен лимит размера блокчейна";
|
||||
case "limit_check_failed" -> "Ошибка проверки лимита размера";
|
||||
case "bad_block_format" -> "Некорректный формат блока";
|
||||
case "bad_app_tag" -> "Некорректный тестовый тег ANS-104";
|
||||
case "bad_channel_tag" -> "Некорректный тег канала ANS-104";
|
||||
case "bad_block_body" -> "Некорректное тело блока";
|
||||
case "bad_channel_name" -> "Некорректное название канала";
|
||||
case "bad_block_number" -> "Некорректный номер блока";
|
||||
@@ -190,7 +193,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
int globalNumberFromReq,
|
||||
String prevGlobalHashHexFromReq,
|
||||
String blockBytesB64,
|
||||
boolean replicateAfterWrite
|
||||
boolean replicateAfterWrite,
|
||||
boolean publishToArweave
|
||||
) {
|
||||
if (blockchainName == null || blockchainName.isBlank()) {
|
||||
log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq);
|
||||
@@ -272,6 +276,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
if (!block.getDataItem().hasTag("App", "test5590")) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_app_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
// body.check()
|
||||
try {
|
||||
block.body.check();
|
||||
@@ -401,6 +409,22 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
||||
}
|
||||
|
||||
// Channel DataItems are indexed by a signed canonical channel slug tag: c=<slug>.
|
||||
try {
|
||||
String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry);
|
||||
String actualChannelSlug = block.getDataItem().tagValue("c");
|
||||
if (expectedChannelSlug != null) {
|
||||
if (!expectedChannelSlug.equals(actualChannelSlug)) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
} else if (actualChannelSlug != null) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("AddBlock: cannot validate channel tag blockchainName={} block={}", blockchainName, block.blockNumber, e);
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
if ((block.type & 0xFFFF) == 1
|
||||
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
try {
|
||||
@@ -458,7 +482,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
// 6) подпись по hash32(preimage)
|
||||
// 6) ANS-104 Ed25519 подпись и owner
|
||||
boolean sigOk;
|
||||
try {
|
||||
sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32);
|
||||
@@ -516,7 +540,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 8) сформировать запись и записать (DB + state + файл)
|
||||
// 8) сформировать запись и атомарно записать в PostgreSQL вместе с новым state
|
||||
try {
|
||||
BlockEntry be = new BlockEntry();
|
||||
be.setLogin(login);
|
||||
@@ -529,6 +553,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
be.setBlockBytes(block.toBytes());
|
||||
be.setBlockHash(block.getHash32());
|
||||
be.setBlockSignature(block.getSignature64());
|
||||
be.setDataItemId(block.getDataItemId32());
|
||||
be.setArweavePublishPending(publishToArweave);
|
||||
|
||||
// line columns (optional)
|
||||
be.setLineCode(lineCode);
|
||||
@@ -626,18 +652,26 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
* Архивный импорт использует ту же проверку и запись, что обычный AddBlock,
|
||||
* но не запускает повторную межсерверную репликацию уже импортированного блока.
|
||||
*/
|
||||
public ArchiveImportResult addBlockFromArchive(String blockchainName, byte[] rawBlockBytes) {
|
||||
public ArweaveImportResult addBlockFromArweave(String blockchainName, byte[] rawBlockBytes) {
|
||||
if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) {
|
||||
return new ArchiveImportResult(false, "chain_resync_in_progress", -1, "");
|
||||
return new ArweaveImportResult(false, "chain_resync_in_progress", -1, "");
|
||||
}
|
||||
if (rawBlockBytes == null || rawBlockBytes.length == 0) {
|
||||
return new ArchiveImportResult(false, "empty_block", -1, "");
|
||||
return new ArweaveImportResult(false, "empty_block", -1, "");
|
||||
}
|
||||
try {
|
||||
BchBlockEntry probe = new BchBlockEntry(rawBlockBytes);
|
||||
if (blocksDAO.existsByDataItemId(probe.getDataItemId32())) {
|
||||
return new ArweaveImportResult(true, "already_present", probe.blockNumber, toHex(probe.getHash32()));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Parsed again below to return the canonical bad_block_format result.
|
||||
}
|
||||
final BchBlockEntry parsed;
|
||||
try {
|
||||
parsed = new BchBlockEntry(rawBlockBytes);
|
||||
} catch (Exception e) {
|
||||
return new ArchiveImportResult(false, "bad_block_format", -1, "");
|
||||
return new ArweaveImportResult(false, "bad_block_format", -1, "");
|
||||
}
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||
lock.lock();
|
||||
@@ -647,20 +681,40 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
parsed.blockNumber,
|
||||
toHex(parsed.prevHash32),
|
||||
Base64Ws.encode(rawBlockBytes),
|
||||
false,
|
||||
false
|
||||
);
|
||||
return new ArchiveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
|
||||
return new ArweaveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public record ArchiveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {}
|
||||
public record ArweaveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Helpers ====================================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
private String expectedChannelSlug(String blockchainName, BchBlockEntry block, ChannelNameStateEntry justCreated) throws Exception {
|
||||
if (block.body instanceof CreateChannelBody) {
|
||||
return justCreated == null ? null : justCreated.getSlug();
|
||||
}
|
||||
int type = block.type & 0xffff;
|
||||
int sub = block.subType & 0xffff;
|
||||
boolean channelBlock = type == 1 && (
|
||||
sub == (MsgSubType.TEXT_POST & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_REPOST & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_CHANNEL_META & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_ENTRYPOINT & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_EXERCISE & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_SERVICE & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_COURSE & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_EDIT_POST & 0xffff));
|
||||
if (!channelBlock || !(block.body instanceof BodyHasLine line) || line.lineCode() <= 0) return null;
|
||||
return channelNameStateDAO.getSlugByOwnerAndRoot(blockchainName, line.lineCode());
|
||||
}
|
||||
|
||||
private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) {
|
||||
if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
&& subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null;
|
||||
|
||||
+15
-122
@@ -13,35 +13,17 @@ import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
|
||||
*
|
||||
* Текущая схема:
|
||||
* 1) собираем <blockchainName>.tmp_bch как готовый кандидат на замену основного файла;
|
||||
* 2) пишем маленький sidecar <blockchainName>.write_check с blockNumber/blockHash;
|
||||
* 3) создаём пустой marker <blockchainName>.write_pending;
|
||||
* 4) выполняем одну SQL-транзакцию;
|
||||
* 5) после commit атомарно заменяем основной .bch из tmp;
|
||||
* 6) убираем временные файлы.
|
||||
*
|
||||
* Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись:
|
||||
* - если marker отсутствует, а tmp/check остались, это мусор;
|
||||
* - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор.
|
||||
/** Stores the complete ANS-104 DataItem and all derived SHiNE state atomically in PostgreSQL.
|
||||
* Local .bch/tmp/marker files are intentionally not used.
|
||||
*/
|
||||
public final class BlockchainWriter {
|
||||
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
|
||||
private final BlocksDAO blocksDAO;
|
||||
private final BlockchainStateDAO stateDAO;
|
||||
@@ -49,7 +31,6 @@ public final class BlockchainWriter {
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
BlockchainStateDAO stateDAO,
|
||||
@@ -63,6 +44,10 @@ public final class BlockchainWriter {
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the complete ANS-104 DataItem and all SQL projections in one DB transaction.
|
||||
* There is intentionally no .bch/tmp/marker file layer anymore.
|
||||
*/
|
||||
public List<String> appendBlockAndState(String blockchainName,
|
||||
BchBlockEntry block,
|
||||
BlockchainStateEntry st,
|
||||
@@ -73,48 +58,24 @@ public final class BlockchainWriter {
|
||||
UserNotificationEntry notificationEntry) throws SQLException {
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
byte[] blockBytes = block.toBytes();
|
||||
byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes);
|
||||
String blockHashHex = HEX.formatHex(block.getHash32());
|
||||
|
||||
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
||||
|
||||
boolean committed = false;
|
||||
byte[] rawDataItem = block.toBytes();
|
||||
List<String> counterChangedLogins = new ArrayList<>();
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
// 1) insert block
|
||||
blocksDAO.insert(c, be);
|
||||
|
||||
// 2) update state
|
||||
st.setLastBlockNumber(block.blockNumber);
|
||||
st.setLastBlockHash(block.getHash32());
|
||||
st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length);
|
||||
st.setFileSizeBytes(st.getFileSizeBytes() + rawDataItem.length);
|
||||
st.setUpdatedAtMs(nowMs);
|
||||
|
||||
stateDAO.upsert(c, st);
|
||||
|
||||
// 2.1) Если блок — USER_PARAM, синхронизируем снимок в users_params в той же транзакции.
|
||||
if (userParamEntry != null) {
|
||||
userParamsDAO.upsertIfNewer(c, userParamEntry);
|
||||
}
|
||||
if (userParamEntry != null) userParamsDAO.upsertIfNewer(c, userParamEntry);
|
||||
if (channelNameStateEntry != null) channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||
if (channelMetaUpdateEntry != null) channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
if (notificationEntry != null) userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
|
||||
if (channelNameStateEntry != null) {
|
||||
channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||
}
|
||||
|
||||
if (channelMetaUpdateEntry != null) {
|
||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
}
|
||||
|
||||
// Notification projection is part of the same SQL transaction as the block.
|
||||
// If notification indexing fails, the block/state write is rolled back as well.
|
||||
if (notificationEntry != null) {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
}
|
||||
|
||||
// Channel read/unread projection is updated in the same SQL transaction as the block.
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
Integer lineCode = be.getLineCode();
|
||||
@@ -126,7 +87,6 @@ public final class BlockchainWriter {
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit UNFOLLOW removes the projection row. FOLLOW never creates it.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|
||||
&& be.getToBchName() != null && !be.getToBchName().isBlank()
|
||||
@@ -138,39 +98,14 @@ public final class BlockchainWriter {
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
c.rollback();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
if (!committed) {
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
}
|
||||
|
||||
if (e instanceof SQLException se) {
|
||||
throw se;
|
||||
}
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (e instanceof SQLException se) throw se;
|
||||
throw new SQLException("appendBlockAndState failed", e);
|
||||
} finally {
|
||||
try {
|
||||
c.setAutoCommit(true);
|
||||
} catch (Exception ignored) {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) После commit — атомарно подменяем основной файл.
|
||||
try {
|
||||
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||
} catch (RuntimeException e) {
|
||||
// marker/check/tmp оставляем для startup-recovery
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 4) После успешной подмены — чистим временные артефакты.
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
return counterChangedLogins;
|
||||
}
|
||||
|
||||
@@ -183,46 +118,4 @@ public final class BlockchainWriter {
|
||||
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||
}
|
||||
|
||||
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
||||
byte[] base;
|
||||
try {
|
||||
if (Files.exists(fs.resolveBlockchainPath(blockchainName))) {
|
||||
base = fs.readBlockchain(blockchainName);
|
||||
} else {
|
||||
base = new byte[0];
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
byte[] out = new byte[base.length + blockBytes.length];
|
||||
System.arraycopy(base, 0, out, 0, base.length);
|
||||
System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) {
|
||||
fs.writeBlockchainTmp(blockchainName, candidateBytes);
|
||||
fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex);
|
||||
fs.writeBlockchainWritePendingMarker(blockchainName);
|
||||
}
|
||||
|
||||
private void cleanupWriteArtifactsBestEffort(String blockchainName) {
|
||||
deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName));
|
||||
deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName));
|
||||
deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName));
|
||||
}
|
||||
|
||||
private static void deleteQuietly(DeleteAction action) {
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface DeleteAction {
|
||||
void run();
|
||||
}
|
||||
}
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.blockchain;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.*;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.*;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.archive.ArchiveBlockchainLocation;
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchiveChainCursor;
|
||||
import shine.db.dao.ArchiveImportDAO;
|
||||
import shine.db.dao.ArchivePublicationDAO;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
public final class Net_GetArchiveBlockchainLocation_Handler implements JsonMessageHandler {
|
||||
@Override public Net_Response handle(Net_Request base, ConnectionContext ctx) {
|
||||
Net_GetArchiveBlockchainLocation_Request req=(Net_GetArchiveBlockchainLocation_Request)base;
|
||||
String bch=String.valueOf(req.getBlockchainName()==null?"":req.getBlockchainName()).trim();
|
||||
if(bch.isEmpty()) return NetExceptionResponseFactory.error(req,WireCodes.Status.BAD_REQUEST,"BAD_FIELDS","blockchainName обязателен");
|
||||
try {
|
||||
ArchiveImportDAO importDao = ArchiveImportDAO.getInstance();
|
||||
ArchiveBlockchainLocation e=importDao.getLocation(bch);
|
||||
if (e == null) {
|
||||
// Backward-compatible fallback для publisher-а, который уже имел v22 cursors до появления v23 location index.
|
||||
ArchivePublicationDAO publicationDao = ArchivePublicationDAO.getInstance();
|
||||
ArchiveChainCursor cursor = publicationDao.getCursor(bch);
|
||||
if (cursor != null) {
|
||||
ArchiveBigBlockRef matching = publicationDao.listFinalizedBigBlocks().stream()
|
||||
.filter(ref -> ref.bigBlockNumber() == cursor.lastArchiveBigBlockNumber())
|
||||
.findFirst().orElse(null);
|
||||
if (matching != null && matching.arweaveTxId() != null && matching.arweaveTxId().length == 32) {
|
||||
String publisher = java.util.Objects.toString(AppConfig.getInstance().getParam("server.SHiNE.login"), "").trim();
|
||||
String txId = Base64.getUrlEncoder().withoutPadding().encodeToString(matching.arweaveTxId());
|
||||
e = new ArchiveBlockchainLocation(
|
||||
bch, publisher, txId, cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(),
|
||||
cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), System.currentTimeMillis());
|
||||
importDao.upsertLocation(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(e==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.NOT_FOUND,"ARCHIVE_LOCATION_NOT_FOUND","Архивная ссылка пока не известна этому серверу");
|
||||
Net_GetArchiveBlockchainLocation_Response r=new Net_GetArchiveBlockchainLocation_Response();
|
||||
r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK);
|
||||
r.setBlockchainName(e.blockchainName()); r.setPublisherLogin(e.publisherLogin()); r.setArweaveTxId(e.arweaveTxId()); r.setArchiveHash(hex(e.archiveHash()));
|
||||
r.setBigBlockNumber(e.bigBlockNumber()); r.setChunkOffset(e.chunkOffset()); r.setChunkSize(e.chunkSize()); r.setSourceLastBlockNumber(e.sourceLastBlockNumber()); return r;
|
||||
} catch(Exception e) { return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"INTERNAL_ERROR",NetExceptionResponseFactory.detailedMessage("Не удалось получить архивную ссылку",e)); }
|
||||
}
|
||||
private static String hex(byte[] b){if(b==null)return"";StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public final class Net_GetArchiveBlockchainLocation_Request extends Net_Request {
|
||||
private String blockchainName;
|
||||
public String getBlockchainName(){return blockchainName;}
|
||||
public void setBlockchainName(String value){this.blockchainName=value;}
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public final class Net_GetArchiveBlockchainLocation_Response extends Net_Response {
|
||||
private String blockchainName,publisherLogin,arweaveTxId,archiveHash;
|
||||
private long bigBlockNumber,chunkOffset,chunkSize,sourceLastBlockNumber;
|
||||
public String getBlockchainName(){return blockchainName;} public void setBlockchainName(String v){blockchainName=v;}
|
||||
public String getPublisherLogin(){return publisherLogin;} public void setPublisherLogin(String v){publisherLogin=v;}
|
||||
public String getArweaveTxId(){return arweaveTxId;} public void setArweaveTxId(String v){arweaveTxId=v;}
|
||||
public String getArchiveHash(){return archiveHash;} public void setArchiveHash(String v){archiveHash=v;}
|
||||
public long getBigBlockNumber(){return bigBlockNumber;} public void setBigBlockNumber(long v){bigBlockNumber=v;}
|
||||
public long getChunkOffset(){return chunkOffset;} public void setChunkOffset(long v){chunkOffset=v;}
|
||||
public long getChunkSize(){return chunkSize;} public void setChunkSize(long v){chunkSize=v;}
|
||||
public long getSourceLastBlockNumber(){return sourceLastBlockNumber;} public void setSourceLastBlockNumber(long v){sourceLastBlockNumber=v;}
|
||||
}
|
||||
+1
-5
@@ -4,7 +4,6 @@ import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
/**
|
||||
* In-memory guard для цепочек, которые сейчас находятся в полном resync.
|
||||
@@ -39,10 +38,7 @@ public final class BlockchainResyncGuard {
|
||||
if (isBypassed(key)) {
|
||||
return false;
|
||||
}
|
||||
if (ACTIVE.contains(key)) {
|
||||
return true;
|
||||
}
|
||||
return FileStoreUtil.getInstance().existsBlockchainResyncMarker(key);
|
||||
return ACTIVE.contains(key);
|
||||
}
|
||||
|
||||
public static <T> T withBypass(String blockchainName, ThrowingSupplier<T> supplier) throws Exception {
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package server.sync;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.entities.SyncServerEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Startup-recovery для цепочек, которые были помечены как resync-pending.
|
||||
*
|
||||
* Правило простое:
|
||||
* - marker-file означает, что chain должен быть пересобран заново;
|
||||
* - мы не пытаемся продолжать с середины;
|
||||
* - если recovery не завершён, обычная работа сервера не стартует.
|
||||
*/
|
||||
public final class BlockchainResyncRecoveryOnStartup {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BlockchainResyncRecoveryOnStartup.class);
|
||||
private static final RemoteBlockchainSyncClient REMOTE = new RemoteBlockchainSyncClient();
|
||||
|
||||
private BlockchainResyncRecoveryOnStartup() {}
|
||||
|
||||
public static void runRecoveryOrThrow() {
|
||||
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
|
||||
ensureDirExists(dataDir);
|
||||
|
||||
List<Path> markers = listMarkerFiles(dataDir);
|
||||
if (markers.isEmpty()) {
|
||||
log.info("🟢 BlockchainResyncRecovery: resync marker-файлы не найдены.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.warn("🟡 BlockchainResyncRecovery: найдено marker-файлов: {}", markers.size());
|
||||
for (Path marker : markers) {
|
||||
recoverSingleMarkerOrThrow(marker);
|
||||
}
|
||||
log.info("✅ BlockchainResyncRecovery: все marker-файлы обработаны.");
|
||||
}
|
||||
|
||||
private static void recoverSingleMarkerOrThrow(Path markerPath) {
|
||||
String fileName = markerPath.getFileName().toString();
|
||||
String blockchainName = extractBlockchainName(fileName);
|
||||
if (blockchainName == null || blockchainName.isBlank()) {
|
||||
throw new IllegalStateException("Bad resync marker name: " + fileName);
|
||||
}
|
||||
|
||||
Map<String, String> meta = parseMarker(markerPath);
|
||||
String partnerLogin = normalize(meta.get("partnerLogin"));
|
||||
String partnerAddress = normalize(meta.get("partnerAddress"));
|
||||
if (partnerAddress == null) {
|
||||
throw new IllegalStateException("Resync marker has no partnerAddress for blockchainName=" + blockchainName);
|
||||
}
|
||||
|
||||
log.warn("🔁 BlockchainResyncRecovery: processing marker blockchainName={} partnerLogin={} partnerAddress={}",
|
||||
blockchainName, partnerLogin, partnerAddress);
|
||||
|
||||
try {
|
||||
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads =
|
||||
REMOTE.listBlockchainHeads(partnerLogin, partnerAddress);
|
||||
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream()
|
||||
.filter(h -> h != null && blockchainName.equals(h.blockchainName()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Partner does not expose blockchainName=" + blockchainName + " address=" + partnerAddress));
|
||||
|
||||
SyncServerEntry partner = new SyncServerEntry(
|
||||
partnerLogin == null ? "" : partnerLogin,
|
||||
partnerAddress,
|
||||
System.currentTimeMillis()
|
||||
);
|
||||
|
||||
PeriodicBlockchainSyncService.resyncFromScratch(partner, remoteHead);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(
|
||||
"Не удалось восстановить resync-помеченную цепочку blockchainName=" + blockchainName,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Path> listMarkerFiles(Path dataDir) {
|
||||
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) {
|
||||
return stream(ds).toList();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot list resync markers in: " + dataDir, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.stream.Stream<Path> stream(DirectoryStream<Path> ds) {
|
||||
return java.util.stream.StreamSupport.stream(ds.spliterator(), false)
|
||||
.filter(Files::isRegularFile);
|
||||
}
|
||||
|
||||
private static Map<String, String> parseMarker(Path path) {
|
||||
try {
|
||||
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
for (String line : lines) {
|
||||
if (line == null) continue;
|
||||
String s = line.trim();
|
||||
if (s.isEmpty() || s.startsWith("#")) continue;
|
||||
int idx = s.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
String key = s.substring(0, idx).trim();
|
||||
String value = s.substring(idx + 1).trim();
|
||||
result.put(key, value);
|
||||
}
|
||||
return result;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot read resync marker: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureDirExists(Path dir) {
|
||||
try {
|
||||
if (!Files.exists(dir)) {
|
||||
Files.createDirectories(dir);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot create data dir: " + dir, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractBlockchainName(String fileName) {
|
||||
if (fileName == null) return null;
|
||||
String s = fileName.trim();
|
||||
if (!s.endsWith(FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) {
|
||||
return null;
|
||||
}
|
||||
return s.substring(0, s.length() - FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION.length());
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String s = value.trim();
|
||||
return s.isEmpty() ? null : s.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||
import shine.db.entities.SyncServerEntry;
|
||||
import server.sync.BlockchainResyncGuard;
|
||||
import utils.files.FileStoreUtil;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
@@ -52,7 +51,6 @@ public final class PeriodicBlockchainSyncService {
|
||||
private static final BlockchainStateDAO STATE_DAO = BlockchainStateDAO.getInstance();
|
||||
private static final SyncServersDAO SYNC_SERVERS_DAO = SyncServersDAO.getInstance();
|
||||
private static final BlockchainResyncCleanupDAO RESYNC_CLEANUP_DAO = BlockchainResyncCleanupDAO.getInstance();
|
||||
private static final FileStoreUtil FILE_STORE = FileStoreUtil.getInstance();
|
||||
private static final SolanaUserPdaCurrentDAO SOLANA_USER_PDA_CURRENT_DAO = SolanaUserPdaCurrentDAO.getInstance();
|
||||
|
||||
private PeriodicBlockchainSyncService() {}
|
||||
@@ -214,26 +212,6 @@ public final class PeriodicBlockchainSyncService {
|
||||
return;
|
||||
}
|
||||
|
||||
String markerContent = """
|
||||
blockchainName=%s
|
||||
partnerLogin=%s
|
||||
partnerAddress=%s
|
||||
remoteLastBlockNumber=%d
|
||||
remoteLastBlockHash=%s
|
||||
remoteFileSizeBytes=%d
|
||||
startedAtMs=%d
|
||||
""".formatted(
|
||||
blockchainName,
|
||||
partnerLogin == null ? "" : partnerLogin,
|
||||
partner.getServerAddress() == null ? "" : partner.getServerAddress(),
|
||||
remoteHead.lastBlockNumber(),
|
||||
remoteHead.lastBlockHash(),
|
||||
remoteHead.fileSizeBytes(),
|
||||
System.currentTimeMillis()
|
||||
);
|
||||
|
||||
FILE_STORE.writeBlockchainResyncMarker(blockchainName, markerContent);
|
||||
|
||||
ReentrantLock chainLock = BlockchainLocks.lockFor(blockchainName);
|
||||
chainLock.lock();
|
||||
boolean success = false;
|
||||
@@ -248,9 +226,6 @@ public final class PeriodicBlockchainSyncService {
|
||||
cleanup.deletedBlocksRows(),
|
||||
cleanup.deletedBlockchainStateRows());
|
||||
|
||||
FILE_STORE.deleteBlockchainFileIfExists(blockchainName);
|
||||
FILE_STORE.deleteBlockchainTmpFileIfExists(blockchainName);
|
||||
|
||||
if (!ensureLocalChainExists(partner, blockchainName)) {
|
||||
throw new IllegalStateException("failed to recreate local chain state for " + blockchainName);
|
||||
}
|
||||
@@ -260,7 +235,6 @@ public final class PeriodicBlockchainSyncService {
|
||||
throw new IllegalStateException("failed to replay remote chain for " + blockchainName);
|
||||
}
|
||||
|
||||
FILE_STORE.deleteBlockchainResyncMarkerIfExists(blockchainName);
|
||||
success = true;
|
||||
log.info("Blockchain resync completed: partner={} blockchainName={} blocks=0..{}",
|
||||
partnerLogin, blockchainName, remoteHead.lastBlockNumber());
|
||||
|
||||
@@ -58,7 +58,15 @@ public final class SolanaUsersSyncStartupService {
|
||||
);
|
||||
|
||||
service.start();
|
||||
try {
|
||||
service.awaitReady();
|
||||
} catch (Exception exception) {
|
||||
log.warn(
|
||||
"Solana users sync is not ready yet; continuing server startup and leaving sync service running",
|
||||
exception
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Solana users sync is READY. Server startup may continue."
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
package server.ws;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
import shine.log.BlockchainAdminNotifier;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* ===============================================================
|
||||
* BlockchainTmpRecoveryOnStartup — восстановление консистентности
|
||||
* файлов обычного AddBlock при старте сервера.
|
||||
*
|
||||
* Новая модель обычной записи:
|
||||
* 1) собирается <name>.tmp_bch как полный кандидат на замену main;
|
||||
* 2) пишется sidecar <name>.write_check (blockNumber/blockHash);
|
||||
* 3) создаётся пустой marker <name>.write_pending;
|
||||
* 4) выполняется SQL-транзакция;
|
||||
* 5) после commit tmp атомарно ставится на место main;
|
||||
* 6) marker и sidecar удаляются.
|
||||
*
|
||||
* На старте:
|
||||
* - если найден marker, recovery добивает запись или чистит мусор;
|
||||
* - если marker нет, а tmp/check остались, это мусор и он удаляется;
|
||||
* - legacy tmp-файлы без marker тоже считаются мусором.
|
||||
*
|
||||
* Принцип:
|
||||
* - marker означает, что операция вошла в опасную фазу и должна быть доведена до конца или откатана;
|
||||
* - sidecar нужен только как маленькое описание текущей операции (blockNumber/blockHash);
|
||||
* - если marker отсутствует, временные файлы не считаются валидными.
|
||||
* ===============================================================
|
||||
*/
|
||||
public final class BlockchainTmpRecoveryOnStartup {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class);
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
|
||||
private BlockchainTmpRecoveryOnStartup() {}
|
||||
|
||||
public static void runRecoveryOrThrow() {
|
||||
FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||||
BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||
|
||||
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
|
||||
ensureDirExists(dataDir);
|
||||
|
||||
List<Path> markers = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION);
|
||||
if (!markers.isEmpty()) {
|
||||
log.warn("🟡 BlockchainTmpRecovery: найдено marker-файлов обычного AddBlock: {}", markers.size());
|
||||
} else {
|
||||
log.info("🟢 BlockchainTmpRecovery: marker-файлов обычного AddBlock не найдено.");
|
||||
}
|
||||
|
||||
for (Path marker : markers) {
|
||||
recoverSingleWriteMarkerOrThrow(marker, fs, stateDAO, blocksDAO);
|
||||
}
|
||||
|
||||
cleanupOrphanTempArtifacts(dataDir, fs);
|
||||
log.info("✅ BlockchainTmpRecovery: обработка временных файлов AddBlock завершена.");
|
||||
}
|
||||
|
||||
private static void recoverSingleWriteMarkerOrThrow(Path markerPath,
|
||||
FileStoreUtil fs,
|
||||
BlockchainStateDAO stateDAO,
|
||||
BlocksDAO blocksDAO) {
|
||||
String markerFileName = markerPath.getFileName().toString();
|
||||
String blockchainName = extractBlockchainName(markerFileName, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION);
|
||||
if (blockchainName == null || blockchainName.isBlank()) {
|
||||
BlockchainAdminNotifier.critical(
|
||||
"НАЙДЕН write_pending marker С НЕОЖИДАННЫМ ИМЕНЕМ: " + markerFileName,
|
||||
null
|
||||
);
|
||||
throw new IllegalStateException("Bad write marker name: " + markerFileName);
|
||||
}
|
||||
|
||||
Path tmpPath = fs.resolveBlockchainTmpPath(blockchainName);
|
||||
Path checkPath = fs.resolveBlockchainWriteCheckPath(blockchainName);
|
||||
Path mainPath = fs.resolveBlockchainPath(blockchainName);
|
||||
|
||||
Map<String, String> meta = parseKeyValueFile(checkPath);
|
||||
Integer expectedBlockNumber = parseInt(meta.get("blockNumber"));
|
||||
String expectedBlockHashHex = normalizeHex(meta.get("blockHash"));
|
||||
|
||||
try {
|
||||
BlockchainStateEntry st = stateDAO.getByBlockchainName(blockchainName);
|
||||
boolean mainExists = Files.exists(mainPath);
|
||||
boolean tmpExists = Files.exists(tmpPath);
|
||||
long mainSize = mainExists ? safeSize(mainPath) : -1L;
|
||||
long tmpSize = tmpExists ? safeSize(tmpPath) : -1L;
|
||||
|
||||
if (st == null) {
|
||||
log.warn("🟠 BlockchainTmpRecovery: marker есть, но blockchain_state отсутствует. blockchainName={}. Удаляем временные файлы.",
|
||||
blockchainName);
|
||||
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||
return;
|
||||
}
|
||||
|
||||
long stateSize = st.getFileSizeBytes();
|
||||
|
||||
if (expectedBlockNumber == null || expectedBlockHashHex == null) {
|
||||
log.warn("🟠 BlockchainTmpRecovery: write_check повреждён или пуст. blockchainName={}. Пробуем recovery по размерам.",
|
||||
blockchainName);
|
||||
|
||||
if (mainExists && mainSize == stateSize) {
|
||||
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||
return;
|
||||
}
|
||||
if (tmpExists && tmpSize == stateSize) {
|
||||
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||
return;
|
||||
}
|
||||
if (tmpExists && mainExists) {
|
||||
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||
return;
|
||||
}
|
||||
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||
return;
|
||||
}
|
||||
|
||||
BlockEntry block = blocksDAO.getByNumber(blockchainName, expectedBlockNumber);
|
||||
if (block == null || block.getBlockHash() == null) {
|
||||
log.warn("🟠 BlockchainTmpRecovery: в blocks нет ожидаемого блока. blockchainName={}, blockNumber={}. Чистим временные файлы.",
|
||||
blockchainName, expectedBlockNumber);
|
||||
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||
return;
|
||||
}
|
||||
|
||||
String actualHashHex = HEX.formatHex(block.getBlockHash());
|
||||
if (!actualHashHex.equalsIgnoreCase(expectedBlockHashHex)) {
|
||||
log.warn("🟠 BlockchainTmpRecovery: hash в write_check не совпал с DB. blockchainName={}, expected={}, actual={}. Чистим временные файлы.",
|
||||
blockchainName, expectedBlockHashHex, actualHashHex);
|
||||
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если main уже совпадает со state — tmp/check/marker лишние.
|
||||
if (mainExists && mainSize == stateSize) {
|
||||
log.info("🟢 BlockchainTmpRecovery: main уже соответствует state. blockchainName={}, stateSize={}, mainSize={}",
|
||||
blockchainName, stateSize, mainSize);
|
||||
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если tmp уже готов и совпадает со state — просто ставим его на место main.
|
||||
if (tmpExists && tmpSize == stateSize) {
|
||||
log.warn("🟡 BlockchainTmpRecovery: tmp соответствует state, восстанавливаем main. blockchainName={}, stateSize={}, tmpSize={}",
|
||||
blockchainName, stateSize, tmpSize);
|
||||
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если tmp нет, но DB уже закоммитила блок — пробуем восстановить tmp из main + block_bytes.
|
||||
if (!tmpExists && mainExists) {
|
||||
long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length;
|
||||
if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) {
|
||||
log.warn("🟡 BlockchainTmpRecovery: tmp отсутствует, но main+DB дают валидный кандидат. blockchainName={}. Восстанавливаем tmp и main.",
|
||||
blockchainName);
|
||||
byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes());
|
||||
fs.writeBlockchainTmp(blockchainName, rebuilt);
|
||||
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Если tmp есть, но его размер не совпал, пробуем восстановить из main + block_bytes.
|
||||
if (mainExists) {
|
||||
long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length;
|
||||
if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) {
|
||||
log.warn("🟡 BlockchainTmpRecovery: tmp/size не совпали, пересобираем tmp из main+block_bytes. blockchainName={}",
|
||||
blockchainName);
|
||||
byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes());
|
||||
fs.writeBlockchainTmp(blockchainName, rebuilt);
|
||||
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||
cleanupWriteArtifacts(markerPath, checkPath, tmpPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Если ничего не совпало, это уже подозрительное состояние.
|
||||
BlockchainAdminNotifier.critical(
|
||||
"НЕСОГЛАСОВАННОСТЬ ОПЕРАЦИИ AddBlock ПРИ СТАРТЕ. blockchainName=" + blockchainName +
|
||||
", stateSize=" + stateSize +
|
||||
", mainExists=" + mainExists +
|
||||
", mainSize=" + mainSize +
|
||||
", tmpExists=" + tmpExists +
|
||||
", tmpSize=" + tmpSize +
|
||||
", expectedBlockNumber=" + expectedBlockNumber +
|
||||
", expectedBlockHash=" + expectedBlockHashHex +
|
||||
". Требуется ручная проверка.",
|
||||
null
|
||||
);
|
||||
throw new IllegalStateException("AddBlock recovery mismatch for " + blockchainName);
|
||||
} catch (SQLException e) {
|
||||
BlockchainAdminNotifier.critical(
|
||||
"ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ AddBlock marker: blockchainName=" + blockchainName,
|
||||
e
|
||||
);
|
||||
throw new IllegalStateException("DB error during AddBlock recovery for " + blockchainName, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void cleanupOrphanTempArtifacts(Path dataDir, FileStoreUtil fs) {
|
||||
List<Path> tmpFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION);
|
||||
List<Path> checkFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION);
|
||||
|
||||
if (tmpFiles.isEmpty() && checkFiles.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.warn("🟡 BlockchainTmpRecovery: найдено orphan tmp/check файлов. tmp={}, check={}", tmpFiles.size(), checkFiles.size());
|
||||
for (Path tmp : tmpFiles) {
|
||||
String blockchainName = extractBlockchainName(tmp.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION);
|
||||
if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) {
|
||||
continue;
|
||||
}
|
||||
safeDelete(tmp);
|
||||
}
|
||||
for (Path check : checkFiles) {
|
||||
String blockchainName = extractBlockchainName(check.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION);
|
||||
if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) {
|
||||
continue;
|
||||
}
|
||||
safeDelete(check);
|
||||
}
|
||||
}
|
||||
|
||||
private static void cleanupWriteArtifacts(Path markerPath, Path checkPath, Path tmpPath) {
|
||||
safeDelete(markerPath);
|
||||
safeDelete(checkPath);
|
||||
safeDelete(tmpPath);
|
||||
}
|
||||
|
||||
private static byte[] rebuildTmpFromMainAndBlock(Path mainPath, byte[] blockBytes) {
|
||||
try {
|
||||
byte[] mainBytes = Files.exists(mainPath) ? Files.readAllBytes(mainPath) : new byte[0];
|
||||
byte[] out = new byte[mainBytes.length + blockBytes.length];
|
||||
System.arraycopy(mainBytes, 0, out, 0, mainBytes.length);
|
||||
System.arraycopy(blockBytes, 0, out, mainBytes.length, blockBytes.length);
|
||||
return out;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot rebuild tmp from main: " + mainPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> parseKeyValueFile(Path path) {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
if (path == null || !Files.exists(path)) {
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) {
|
||||
if (line == null) continue;
|
||||
String s = line.trim();
|
||||
if (s.isEmpty() || s.startsWith("#")) continue;
|
||||
int idx = s.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
String key = s.substring(0, idx).trim();
|
||||
String value = s.substring(idx + 1).trim();
|
||||
result.put(key, value);
|
||||
}
|
||||
return result;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot read write_check file: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer parseInt(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
try {
|
||||
return Integer.parseInt(value.trim());
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeHex(String value) {
|
||||
if (value == null) return null;
|
||||
String s = value.trim();
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
private static void ensureDirExists(Path dir) {
|
||||
try {
|
||||
if (!Files.exists(dir)) {
|
||||
Files.createDirectories(dir);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot create data dir: " + dir, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Path> listFilesWithSuffix(Path dataDir, String suffix) {
|
||||
List<Path> out = new ArrayList<>();
|
||||
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + suffix)) {
|
||||
for (Path p : ds) {
|
||||
if (Files.isRegularFile(p)) {
|
||||
out.add(p);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot list files in: " + dataDir + " suffix=" + suffix, e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String extractBlockchainName(String fileName, String suffix) {
|
||||
if (fileName == null) return null;
|
||||
String s = fileName.trim();
|
||||
if (!s.endsWith(suffix)) return null;
|
||||
String base = s.substring(0, s.length() - suffix.length());
|
||||
if (base.isBlank()) return null;
|
||||
if (base.contains("/") || base.contains("\\") || base.contains("..")) return null;
|
||||
return base;
|
||||
}
|
||||
|
||||
private static long safeSize(Path p) {
|
||||
try {
|
||||
return Files.size(p);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot read file size: " + p, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void safeDelete(Path p) {
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot delete file: " + p, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,10 @@ import org.eclipse.jetty.servlet.ServletContextHandler;
|
||||
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.archive.ArchivePublisherScheduler;
|
||||
import server.archive.ArchiveImportScheduler;
|
||||
import server.archive.ArweaveBlockPublisherScheduler;
|
||||
import server.archive.ArweaveBlockSyncScheduler;
|
||||
import server.debug.DebugApiConfigurator;
|
||||
import server.files.DmFileApiConfigurator;
|
||||
import server.sync.BlockchainResyncRecoveryOnStartup;
|
||||
import server.sync.PeriodicBlockchainSyncService;
|
||||
import server.sync.PeriodicDmDeliveryService;
|
||||
import server.sync.SolanaUsersSyncStartupService;
|
||||
@@ -22,10 +21,7 @@ import java.time.Duration;
|
||||
/**
|
||||
* WsServer — поднимает Jetty WS на /ws.
|
||||
*
|
||||
* ВАЖНО:
|
||||
* - перед стартом сервера выполняем recovery tmp-блокчейнов.
|
||||
* - если обнаружена несогласованность, которую сервер сам чинить не может —
|
||||
* recovery бросает исключение и сервер не стартует.
|
||||
* Пользовательские блоки хранятся в PostgreSQL; локальные .bch-файлы больше не используются.
|
||||
*/
|
||||
public final class WsServer {
|
||||
|
||||
@@ -33,28 +29,6 @@ public final class WsServer {
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
|
||||
// ============================================================
|
||||
// 0) Восстановление консистентности blockchain файлов
|
||||
// ============================================================
|
||||
try {
|
||||
BlockchainTmpRecoveryOnStartup.runRecoveryOrThrow();
|
||||
} catch (Exception e) {
|
||||
// Уже должно быть “большое” уведомление через BlockchainAdminNotifier,
|
||||
// но на всякий случай логируем ещё раз.
|
||||
log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain tmp-файлов.", e);
|
||||
throw e; // останавливаем запуск
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 0.1) Восстановление цепочек, зависших на full resync
|
||||
// ============================================================
|
||||
try {
|
||||
BlockchainResyncRecoveryOnStartup.runRecoveryOrThrow();
|
||||
} catch (Exception e) {
|
||||
log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain resync-маркеров.", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1.0) Синхронизация пользовательских Solana PDA
|
||||
// ============================================================
|
||||
@@ -84,10 +58,9 @@ public final class WsServer {
|
||||
// ============================================================
|
||||
PeriodicBlockchainSyncService.startOrLog();
|
||||
|
||||
// Опциональная серверная публикация больших SHINE-ARCHIVE блоков.
|
||||
ArchivePublisherScheduler.startOrLog();
|
||||
// Импорт больших архивов только от publisher-ов из archive.import.allowedPublishers.
|
||||
ArchiveImportScheduler.startOrLog();
|
||||
// ANS-104: publish locally-created signed user blocks and discover blocks published by other servers.
|
||||
ArweaveBlockPublisherScheduler.startOrLog();
|
||||
ArweaveBlockSyncScheduler.startOrLog();
|
||||
|
||||
// ============================================================
|
||||
// 2) Запуск Jetty WS
|
||||
@@ -117,7 +90,7 @@ public final class WsServer {
|
||||
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
|
||||
ServerConnectionPool.getInstance().startOrLog();
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(
|
||||
() -> { ServerConnectionPool.getInstance().close(); ArchivePublisherScheduler.close(); ArchiveImportScheduler.close(); },
|
||||
() -> { ServerConnectionPool.getInstance().close(); ArweaveBlockPublisherScheduler.close(); ArweaveBlockSyncScheduler.close(); },
|
||||
"server-connection-pool-shutdown"));
|
||||
PeriodicDmDeliveryService.startOrLog();
|
||||
server.join();
|
||||
|
||||
@@ -14,34 +14,6 @@ solana.users.sync.dbUser=
|
||||
solana.users.sync.dbPassword=
|
||||
solana.users.sync.pollIntervalSeconds=300
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# SHiNE archive publisher (Arweave + User PDA archive head)
|
||||
# По умолчанию выключен: большинство серверов только читают/синхронизируют блоки.
|
||||
# При включении готовые большие файлы сначала сохраняются локально в data/archive,
|
||||
# затем загружаются в Arweave и только после подтверждения фиксируются в Solana.
|
||||
# ------------------------------------------------------------
|
||||
archive.publish.enabled=false
|
||||
archive.publish.time=00:00
|
||||
# Пусто = системная timezone сервера. Можно задать, например Europe/Warsaw.
|
||||
archive.publish.zoneId=
|
||||
archive.maxFileBytes=4000000000
|
||||
archive.workDir=data/archive
|
||||
|
||||
archive.arweave.gateway=https://arweave.net
|
||||
archive.arweave.walletJwkPath=
|
||||
archive.arweave.minConfirmations=1
|
||||
archive.arweave.confirmPollSeconds=30
|
||||
archive.arweave.confirmTimeoutMinutes=180
|
||||
|
||||
# Для записи archive head используется уже существующий Solana RPC:
|
||||
# solana.users.sync.rpcUrl (если задан), иначе solana.rpcUrl.
|
||||
# Отдельного archive.solana.rpcUrl нет.
|
||||
archive.solana.rootKeyPath=
|
||||
archive.solana.clientKeyPath=
|
||||
archive.solana.confirmPollSeconds=5
|
||||
archive.solana.confirmTimeoutMinutes=30
|
||||
archive.solana.commitment=finalized
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Межсерверная синхронизация: как создавать локальную запись пользователя,
|
||||
# если во время sync пришла чужая цепочка, а у нас такого login ещё нет.
|
||||
@@ -156,8 +128,25 @@ test.freeAvatar.maxBytes=131072
|
||||
test.freeAvatar.walletAddress=
|
||||
test.freeAvatar.walletJwkPath=
|
||||
|
||||
# Trusted SHINE-ARCHIVE import. Empty list = do not trust/import archives from anyone.
|
||||
# Comma-separated SHiNE server logins, case-insensitive.
|
||||
archive.import.allowedPublishers=
|
||||
archive.import.intervalMinutes=60
|
||||
archive.import.workDir=data/archive-import
|
||||
# ============================================================
|
||||
# Arweave per-user-block transport (ANS-104)
|
||||
# Test namespace: each user DataItem is signed with App=test5590.
|
||||
# Channel DataItems additionally contain c=<canonical-channel-slug>.
|
||||
# ============================================================
|
||||
arweave.blocks.publish.enabled=false
|
||||
arweave.blocks.publish.intervalMinutes=15
|
||||
arweave.blocks.publish.maxItems=10000
|
||||
arweave.blocks.publish.maxBundleBytes=134217728
|
||||
arweave.blocks.publish.gateway=https://arweave.net
|
||||
arweave.blocks.publish.walletJwkPath=
|
||||
arweave.blocks.publish.minConfirmations=0
|
||||
arweave.blocks.publish.confirmPollSeconds=30
|
||||
arweave.blocks.publish.confirmTimeoutMinutes=180
|
||||
|
||||
arweave.blocks.sync.enabled=false
|
||||
arweave.blocks.sync.intervalMinutes=15
|
||||
arweave.blocks.sync.gateway=https://turbo-gateway.com
|
||||
arweave.blocks.sync.pageSize=100
|
||||
arweave.blocks.sync.queueBatchSize=10000
|
||||
arweave.blocks.sync.startBlockHeight=0
|
||||
arweave.blocks.sync.maxRootBundleBytes=268435456
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package test.it.blockchain;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
import blockchain.BchBlockEntry;
|
||||
import blockchain.body.*;
|
||||
import test.it.utils.TestConfig;
|
||||
@@ -9,20 +10,19 @@ import test.it.utils.log.TestLog;
|
||||
import test.it.utils.ws.WsSession;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/**
|
||||
* AddBlockSender — под новый формат BchBlockEntry (Frame v0):
|
||||
* - blockBytes = preimage + sigMarker(2) + signature64
|
||||
* - preimage начинается с frameCode(2) = 0x0000
|
||||
* - hash32 вычисляется как sha256(preimage)
|
||||
* - signature = Ed25519.sign(hash32)
|
||||
*
|
||||
* ВАЖНО:
|
||||
* - Линии по ТЗ ведём на стороне сервера/БД (триггеры), а в тестах считаем локально.
|
||||
* AddBlockSender для Frame v1: blockBytesB64 содержит готовый ANS-104 DataItem.
|
||||
* SHiNE hash цепочки остаётся SHA-256(Frame v1 bytes), а пользователь подписывает
|
||||
* ANS-104 deep-hash одним Ed25519 ключом.
|
||||
*/
|
||||
public final class AddBlockSender {
|
||||
|
||||
@@ -34,6 +34,7 @@ public final class AddBlockSender {
|
||||
private final String login;
|
||||
private final String blockchainName;
|
||||
private final byte[] loginPrivKey;
|
||||
private final Map<Integer, String> channelSlugByRoot = new HashMap<>();
|
||||
|
||||
public AddBlockSender(WsSession ws, ChainState state, String login, String blockchainName, byte[] loginPrivKey) {
|
||||
this.ws = ws;
|
||||
@@ -75,22 +76,18 @@ public final class AddBlockSender {
|
||||
|
||||
byte[] bodyBytes = body.toBytes();
|
||||
|
||||
// ВАЖНО: preimage должен быть БАЙТ-В-БАЙТ таким же, как в BchBlockEntry
|
||||
byte[] preimage = buildPreimage(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes);
|
||||
byte[] frame = buildFrame(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes);
|
||||
byte[] owner32 = utils.crypto.Ed25519Util.derivePublicKey(loginPrivKey);
|
||||
|
||||
byte[] hash32 = blockchain.BchCryptoVerifier.sha256(preimage);
|
||||
byte[] signature64 = utils.crypto.Ed25519Util.sign(hash32, loginPrivKey);
|
||||
List<Ans104DataItem.Tag> tags = new ArrayList<>();
|
||||
tags.add(new Ans104DataItem.Tag("App", "test5590"));
|
||||
String channelSlug = channelSlugFor(body);
|
||||
if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c", channelSlug));
|
||||
|
||||
BchBlockEntry entry = new BchBlockEntry(
|
||||
prevHash32,
|
||||
blockNumber,
|
||||
tsSec,
|
||||
type,
|
||||
subType,
|
||||
version,
|
||||
bodyBytes,
|
||||
signature64
|
||||
);
|
||||
byte[] signingMessage = Ans104DataItem.buildSigningMessage(owner32, tags, frame);
|
||||
byte[] signature64 = utils.crypto.Ed25519Util.sign(signingMessage, loginPrivKey);
|
||||
byte[] rawDataItem = Ans104DataItem.serializeEd25519(signature64, owner32, tags, frame);
|
||||
BchBlockEntry entry = new BchBlockEntry(rawDataItem);
|
||||
|
||||
String prevHashHexForReq = (blockNumber == 0) ? ZERO64 : state.lastBlockHashHex();
|
||||
|
||||
@@ -119,6 +116,9 @@ public final class AddBlockSender {
|
||||
assertEquals(localHashHex, serverLastHash, op + ": serverLastBlockHash must match local hash");
|
||||
|
||||
state.applyAppendedBlock(blockNumber, entry.getHash32(), isHeader, type, body);
|
||||
if (body instanceof CreateChannelBody ccb) {
|
||||
channelSlugByRoot.put(blockNumber, shine.db.channels.ChannelNameRules.toCanonicalSlug(ccb.channelName));
|
||||
}
|
||||
|
||||
if (TestConfig.DEBUG()) TestLog.info(op + ": state updated");
|
||||
}
|
||||
@@ -194,9 +194,23 @@ public final class AddBlockSender {
|
||||
throw new IllegalArgumentException("Unknown body class: " + body.getClass());
|
||||
}
|
||||
|
||||
// ---------- preimage builder (строго по BchBlockEntry Frame v0) ----------
|
||||
// ---------- Frame v1 + signed channel tag helpers ----------
|
||||
|
||||
private static byte[] buildPreimage(byte[] prevHash32,
|
||||
private String channelSlugFor(BodyRecord body) {
|
||||
if (body instanceof CreateChannelBody ccb) {
|
||||
return shine.db.channels.ChannelNameRules.toCanonicalSlug(ccb.channelName);
|
||||
}
|
||||
if (body instanceof TextBody tb && tb.isLineMessage() && tb.lineCode > 0) {
|
||||
String slug = channelSlugByRoot.get(tb.lineCode);
|
||||
if (slug == null) {
|
||||
throw new IllegalStateException("Unknown channel slug for lineCode=" + tb.lineCode);
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] buildFrame(byte[] prevHash32,
|
||||
int blockNumber,
|
||||
long tsSec,
|
||||
short type,
|
||||
@@ -208,34 +222,19 @@ public final class AddBlockSender {
|
||||
throw new IllegalArgumentException("prevHash32 must be 32 bytes");
|
||||
}
|
||||
|
||||
int bodyLen = (bodyBytes == null ? 0 : bodyBytes.length);
|
||||
int blockSize = BchBlockEntry.PREIMAGE_HEADER_SIZE + bodyLen;
|
||||
|
||||
int bodyLen = bodyBytes == null ? 0 : bodyBytes.length;
|
||||
int blockSize = BchBlockEntry.FRAME_HEADER_SIZE + bodyLen;
|
||||
java.nio.ByteBuffer bb = java.nio.ByteBuffer.allocate(blockSize).order(java.nio.ByteOrder.BIG_ENDIAN);
|
||||
|
||||
// [2] frameCode (v0)
|
||||
bb.putShort((short) (BchBlockEntry.FRAME_CODE_V0 & 0xFFFF));
|
||||
|
||||
// [32] prevHash32
|
||||
bb.putShort((short) BchBlockEntry.FRAME_CODE_V1);
|
||||
bb.put(prevHash32);
|
||||
|
||||
// [4] blockSize (preimage size)
|
||||
bb.putInt(blockSize);
|
||||
|
||||
// [4] blockNumber
|
||||
bb.putInt(blockNumber);
|
||||
|
||||
// [8] timestamp
|
||||
bb.putLong(tsSec);
|
||||
|
||||
// [2][2][2] type/subType/version
|
||||
bb.putShort(type);
|
||||
bb.putShort(subType);
|
||||
bb.putShort(version);
|
||||
|
||||
// [N] bodyBytes
|
||||
if (bodyBytes != null) bb.put(bodyBytes);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
}
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.12.19
|
||||
server.version=1.10.7
|
||||
client.version=1.12.20
|
||||
server.version=1.10.8
|
||||
|
||||
@@ -1,310 +1,49 @@
|
||||
# API для разработчиков: 04 — Запись и чтение блока блокчейна
|
||||
# AddBlock API
|
||||
|
||||
Документ описывает **текущий рабочий формат** сетевых вызовов:
|
||||
## Назначение
|
||||
|
||||
- `AddBlock` — запись любого блока в блокчейн пользователя;
|
||||
- `GetBlockchainBlock` — публичное чтение одного конкретного блока по имени цепочки и номеру.
|
||||
Добавляет один готовый подписанный пользовательский SHiNE Frame v1 / ANS-104 DataItem в blockchain.
|
||||
|
||||
`GetBlockchainBlock` нужен в том числе для межсерверной синхронизации и для открытого чтения публичного блокчейна по одному блоку.
|
||||
|
||||
> Важный принцип: на уровне JSON API сейчас есть **один универсальный метод** записи — `AddBlock`.
|
||||
> Конкретный смысл записи задаётся типом самого бинарного блока (`type/subType/version` в заголовке блока).
|
||||
|
||||
## 1. Что делает `AddBlock`
|
||||
|
||||
`AddBlock`:
|
||||
- принимает имя блокчейна и base64 бинарного блока;
|
||||
- проверяет непрерывность цепочки (`blockNumber`, `prevHash`);
|
||||
- проверяет формат и подпись Ed25519;
|
||||
- валидирует `body` по правилам типа блока;
|
||||
- сохраняет блок и обновляет состояние цепочки.
|
||||
|
||||
## 2. JSON формат запроса
|
||||
|
||||
`op = "AddBlock"`.
|
||||
## Request
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "AddBlock",
|
||||
"requestId": "req-1001",
|
||||
"requestId": "...",
|
||||
"payload": {
|
||||
"blockchainName": "alice-001",
|
||||
"blockNumber": 12,
|
||||
"prevBlockHash": "ab12...ff",
|
||||
"blockBytesB64": "AAAB..."
|
||||
"blockchainName": "alice-...",
|
||||
"blockNumber": 42,
|
||||
"prevBlockHash": "64 hex chars",
|
||||
"blockBytesB64": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Поля `payload`:
|
||||
- `blockchainName` — обязательно, формат `login-NNN`.
|
||||
- `blockNumber` — обязательно (временное legacy-поле для совместимости; должно совпасть с номером внутри бинарного блока).
|
||||
- `prevBlockHash` — legacy-поле, сейчас сервер использует `prevHash` из бинарного блока и состояние цепочки.
|
||||
- `blockBytesB64` — обязательно: **полный бинарный блок** (`preimage + sigMarker + signature`) в Base64.
|
||||
- `blockchainName` — целевая пользовательская chain;
|
||||
- `blockNumber` — должен совпадать с номером внутри Frame v1 и быть `serverLast + 1`;
|
||||
- `prevBlockHash` — 32-byte SHiNE hash предыдущего Frame в hex; должен совпадать с `prevHash32` внутри Frame и серверной вершиной;
|
||||
- `blockBytesB64` — **полный serialized ANS-104 DataItem** в Base64.
|
||||
|
||||
## 3. Успешный ответ
|
||||
Старый формат `preimage + sigMarker + signature` не поддерживается.
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "AddBlock",
|
||||
"requestId": "req-1001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"reasonCode": null,
|
||||
"serverLastGlobalNumber": 12,
|
||||
"serverLastGlobalHash": "9f0e...a1"
|
||||
}
|
||||
}
|
||||
```
|
||||
## Требования к DataItem
|
||||
|
||||
## 4. Ошибка (единый формат)
|
||||
- generic Ed25519 signature type `2`;
|
||||
- Ed25519 signature 64 bytes;
|
||||
- owner 32 bytes и равен текущему blockchain public key;
|
||||
- обязательный тестовый tag `App=test5590`;
|
||||
- для channel block — `c=<canonical_channel_slug>`;
|
||||
- `data` содержит SHiNE Frame v1 (`frameCode=1`).
|
||||
|
||||
При ошибках сервер отдаёт `Net_Exception_Response` со стандартными полями и дополнительно с состоянием сервера для ресинка:
|
||||
## Основные ошибки
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "AddBlock",
|
||||
"requestId": "req-1001",
|
||||
"status": 400,
|
||||
"ok": false,
|
||||
"error": "bad_prev_hash",
|
||||
"message": "Некорректный prevHash (цепочка не совпадает)",
|
||||
"payload": {
|
||||
"serverLastGlobalNumber": 11,
|
||||
"serverLastGlobalHash": "c3d4...98"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `bad_app_tag` — отсутствует/неверен `App=test5590`;
|
||||
- `bad_channel_tag` — `c` отсутствует, лишний или не совпадает с canonical slug;
|
||||
- `bad_signature` / `signature_verify_failed` — DataItem не подписан текущим blockchain key;
|
||||
- `bad_block_number` — нарушена последовательность;
|
||||
- `bad_prev_hash` — нарушена SHiNE hash chain;
|
||||
- `bad_block_bytes` — DataItem/Frame не парсится.
|
||||
|
||||
### Основные `reasonCode`
|
||||
## Storage/publish
|
||||
|
||||
- `empty_blockchain_name`, `bad_blockchain_name`
|
||||
- `blockchain_state_not_found`
|
||||
- `bad_block_base64`, `bad_block_format`, `bad_block_body`
|
||||
- `bad_block_number`, `req_global_mismatch`, `bad_prev_hash`
|
||||
- `bad_signature`, `signature_verify_failed`
|
||||
- `prev_line_block_not_found`, `bad_prev_line_hash`
|
||||
- `limit_exceeded`
|
||||
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
|
||||
- `repost_disabled` — репосты временно отключены до будущей реализации
|
||||
- `entrypoint_edit_forbidden` — `TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST`
|
||||
- `status_confirmed_target_must_be_status_action` — `STATUS_CONFIRMED` должен ссылаться на статусный блок
|
||||
- `status_action_target_not_allowed` — выбранный `STATUS_ACTION` нельзя ставить на этот тип материала
|
||||
- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META`
|
||||
- `internal_error`
|
||||
|
||||
## 5. Какие блоки реально можно добавлять через `AddBlock`
|
||||
|
||||
Через `AddBlock` можно писать поддержанные форматы, кроме явно отключённых временных фич:
|
||||
|
||||
1. **TECH (type=0)**
|
||||
- `HEADER_COMPAT (subType=0)`
|
||||
- `TECH_CREATE_CHANNEL (subType=1)`
|
||||
|
||||
2. **TEXT (type=1)**
|
||||
- `TEXT_POST (10)`
|
||||
- `TEXT_EDIT_POST (11)`
|
||||
- `TEXT_REPLY (20)`
|
||||
- `TEXT_EDIT_REPLY (21)`
|
||||
- `TEXT_RATING (30)` — target-based отзыв на конкретный блок
|
||||
- `TEXT_REPOST (50)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
|
||||
- `TEXT_CHANNEL_META (90)` — скрытый технический снимок профиля канала
|
||||
- `TEXT_ENTRYPOINT (100)` — входная страница канала
|
||||
- `TEXT_EXERCISE (110)` — line-based материал упражнения
|
||||
- `TEXT_SERVICE (120)` — line-based материал услуги / процедуры
|
||||
- `TEXT_COURSE (130)` — line-based материал курса
|
||||
|
||||
3. **REACTION (type=2)**
|
||||
- `REACTION_LIKE (1)`
|
||||
|
||||
4. **CONNECTION (type=3)**
|
||||
- `CONNECTION_FRIEND (10)`
|
||||
- `CONNECTION_UNFRIEND (11)`
|
||||
- `CONNECTION_CONTACT (20)`
|
||||
- `CONNECTION_UNCONTACT (21)`
|
||||
- `CONNECTION_FOLLOW (30)`
|
||||
- `CONNECTION_UNFOLLOW (31)`
|
||||
- `CONNECTION_SPOUSE (40)`
|
||||
- `CONNECTION_UNSPOUSE (41)`
|
||||
- `CONNECTION_PARENT (50)`
|
||||
- `CONNECTION_UNPARENT (51)`
|
||||
- `CONNECTION_CHILD (52)`
|
||||
- `CONNECTION_UNCHILD (53)`
|
||||
- `CONNECTION_SIBLING (54)`
|
||||
- `CONNECTION_UNSIBLING (55)`
|
||||
- `CONNECTION_KNOWN_PERSON (60)`
|
||||
- `CONNECTION_UNKNOWN_PERSON (61)`
|
||||
- `CONNECTION_SHINE_CONFIRMED (70)`
|
||||
- `CONNECTION_SHINE_UNCONFIRMED (71)`
|
||||
- `CONNECTION_SHINE_SEEN (74)`
|
||||
- `CONNECTION_SHINE_UNSEEN (75)`
|
||||
|
||||
5. **USER_PARAM (type=4)**
|
||||
- `USER_PARAM_TEXT_TEXT (1)`
|
||||
|
||||
6. **STATUS_ACTION (type=5)**
|
||||
- `STATUS_DONE_ONCE (10)`
|
||||
- `STATUS_LEARNED (20)`
|
||||
- `STATUS_SERVICE_PASSED (30)`
|
||||
- `STATUS_CONFIRMED (100)`
|
||||
- `STATUS_INTERESTED (110)`
|
||||
- `STATUS_STARTED (120)`
|
||||
- `STATUS_IN_STUDY (130)`
|
||||
- `STATUS_ABANDONED (140)`
|
||||
- `STATUS_COMPLETED (150)`
|
||||
|
||||
## 6. Практические payload-форматы для каналов и вложений
|
||||
|
||||
`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата.
|
||||
|
||||
### Вложения в сообщениях
|
||||
|
||||
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `S:att v=1`.
|
||||
|
||||
Пример текстового содержимого body:
|
||||
|
||||
```text
|
||||
<S:att;v=1;nm=photo.jpg;sz=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||
<S:att;v=1;nm=report.pdf;sz=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||
Текст сообщения
|
||||
```
|
||||
|
||||
Пример вложения с отдельным preview-файлом:
|
||||
|
||||
```text
|
||||
<S:att;v=1;nm=video.mp4;sz=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;preAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;preSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
||||
```
|
||||
|
||||
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
|
||||
|
||||
### Создание публичного канала с профилем
|
||||
|
||||
Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст:
|
||||
|
||||
```text
|
||||
<S:title;v=1;Человекочитаемое имя канала>
|
||||
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||
Описание канала
|
||||
```
|
||||
|
||||
Ограничение `channelDescription` — до `2048` UTF-8 байт. Новый клиент не пишет отдельный `TEXT_CHANNEL_META` сразу после создания канала: создание канала и начальный профиль должны попадать в один `TECH_CREATE_CHANNEL`.
|
||||
|
||||
### Изменение профиля канала
|
||||
|
||||
Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=90)`.
|
||||
|
||||
Текстовое содержимое body использует тот же формат полного снимка профиля:
|
||||
|
||||
```text
|
||||
<S:title;v=1;Новое имя канала>
|
||||
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||
Новое описание канала
|
||||
```
|
||||
|
||||
Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `S:ava` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`.
|
||||
|
||||
## 7. Хватает ли функций сейчас
|
||||
|
||||
Коротко: **для записи событий в блокчейн — хватает**, для полноценного клиентского чтения — **пока не хватает**.
|
||||
|
||||
Что есть:
|
||||
- единый надёжный write-путь `AddBlock`;
|
||||
- есть `GetFriendsLists` и API по `UserParam`;
|
||||
- есть унифицированные коды ошибок и поля для ресинхронизации.
|
||||
|
||||
Что пока ограничивает продукт:
|
||||
- нет полноценного read API для каналов/постов/тредов;
|
||||
- нет API списка подписок с серверными счётчиками непрочитанного;
|
||||
- нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC.
|
||||
|
||||
## 8. Рекомендации по клиенту при записи блоков
|
||||
|
||||
1. Перед отправкой держать локальный `lastNumber/lastHash`.
|
||||
2. При `bad_prev_hash` или `bad_block_number`:
|
||||
- взять `serverLastGlobalNumber/serverLastGlobalHash` из ошибки,
|
||||
- пересобрать следующий блок на актуальной вершине.
|
||||
3. Для edit-блоков всегда ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
||||
4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост.
|
||||
|
||||
|
||||
## 9. USER_PARAM для «личных данных»
|
||||
|
||||
Да, на текущем API это можно добавить **без изменения серверного кода**:
|
||||
|
||||
- в `UserParam` поле `param` сейчас не ограничено фиксированным справочником;
|
||||
- сервер хранит пары `param -> value` как строки (при наличии корректной подписи и `time_ms`);
|
||||
- чтение уже есть через `GetUserParam` и `ListUserParams`.
|
||||
|
||||
Рекомендуемый стартовый набор ключей для профиля (MVP):
|
||||
|
||||
- `name`
|
||||
- `last_name`
|
||||
- `address_physical`
|
||||
- `address_web`
|
||||
- `phone`
|
||||
|
||||
Практическая рекомендация: заранее зафиксировать единый словарь ключей в клиенте/документации, чтобы избежать дублей вида `lastname` vs `last_name`, `site` vs `address_web` и т.д.
|
||||
|
||||
Ограничения, которые важно учесть:
|
||||
|
||||
- сейчас нет серверной ACL-политики чтения параметров (в MVP их может читать любой клиент, который знает `login`);
|
||||
- нет валидации формата значений для конкретных ключей (телефон, URL и т.д. проверяются только на стороне клиента);
|
||||
- нет отдельного индекса/поиска по этим полям — только точечное чтение и listing по `login`.
|
||||
|
||||
---
|
||||
|
||||
## 10. `GetBlockchainBlock`
|
||||
|
||||
### Назначение
|
||||
|
||||
Публичное чтение одного конкретного блока из цепочки.
|
||||
|
||||
Нужно для:
|
||||
|
||||
- открытого чтения блокчейна по одному блоку;
|
||||
- межсерверной синхронизации;
|
||||
- восстановления/докачки отсутствующего хвоста цепочки.
|
||||
|
||||
### JSON формат запроса
|
||||
|
||||
`op = "GetBlockchainBlock"`.
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetBlockchainBlock",
|
||||
"requestId": "req-2001",
|
||||
"payload": {
|
||||
"blockchainName": "alice-001",
|
||||
"blockNumber": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Поля `payload`:
|
||||
|
||||
- `blockchainName` — обязательно, формат `login-NNN`.
|
||||
- `blockNumber` — обязательно, номер блока в цепочке, `>= 0`.
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetBlockchainBlock",
|
||||
"requestId": "req-2001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"blockchainName": "alice-001",
|
||||
"blockNumber": 12,
|
||||
"blockHash": "9f0eaabbccddeeff00112233445566778899aabbccddeeff0011223344556677",
|
||||
"blockBytesB64": "AAAB..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ошибки
|
||||
|
||||
- `400 / BAD_FIELDS` — некорректные `blockchainName` или `blockNumber`.
|
||||
- `404 / BLOCK_NOT_FOUND` — такого блока нет.
|
||||
- `500 / INTERNAL_ERROR` — внутренняя ошибка сервера.
|
||||
После успешного локального AddBlock полный DataItem хранится в PostgreSQL и ставится в Arweave publish queue. Импорт из Arweave использует ту же проверку, но не ставит блок на повторную публикацию.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,137 +0,0 @@
|
||||
# Карта реализации SHiNE Archive Publisher v1.0
|
||||
|
||||
Этот документ связывает протокол с конкретными файлами проекта. Он нужен, чтобы другой агент мог быстро понять, где искать каждую часть реализации.
|
||||
|
||||
## 1. Новый Java-модуль `shine-server-archive`
|
||||
|
||||
Путь: `SHiNE-server/shine-server-archive/`.
|
||||
|
||||
Основные классы:
|
||||
|
||||
- `ArchivePublisherScheduler` — включает publisher только при `archive.publish.enabled=true`, сразу продолжает незавершённый job после рестарта и планирует новый snapshot один раз в сутки в `archive.publish.time`. Следующая дата вычисляется по `ZoneId`, а не как `+24h`, поэтому DST не сдвигает локальную полночь.
|
||||
- `ArchivePublisherService` — state machine: snapshot → локальный файл → Arweave → confirmations → User PDA → Solana finalized → cursor commit.
|
||||
- `ArchivePublisherConfig` — читает настройки. Для Solana RPC отдельного archive URL нет: используется `solana.users.sync.rpcUrl`, затем fallback на `solana.rpcUrl`.
|
||||
- `ShineArchiveWriter` — сериализует бинарный big block `SHINE-ARCHIVE v1.0`, FULL reference table, по одному chunk на `blockchain_name`, footer/hash/signature.
|
||||
- `ArchiveFileNames` — временное и финальное локальные имена.
|
||||
- `ArweaveArchiveService` + `ArweaveMerkle` — Arweave v2 transaction + chunk upload + polling confirmations.
|
||||
- `SolanaArchiveHeadWriter` — обычный `update_user_pda`: root signature + текущая last-block signature + client key fee payer, затем ожидание `finalized`.
|
||||
- `ArchiveKeyLoader` — читает Ed25519 seed/keypair из raw 32/64 bytes, Solana JSON 32/64 или Base64/PKCS8.
|
||||
|
||||
## 2. Локальное состояние PostgreSQL
|
||||
|
||||
Миграция: `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql`.
|
||||
|
||||
Таблицы:
|
||||
|
||||
### `archive_chain_cursor`
|
||||
Одна строка на `blockchain_name`. Хранит только **последнее окончательно опубликованное** состояние:
|
||||
|
||||
- последний source block number/hash;
|
||||
- big block number/hash, где находится последний chunk;
|
||||
- offset/size последнего chunk.
|
||||
|
||||
Если blockchain не попала в новый big block, эта строка не меняется.
|
||||
|
||||
### `archive_publish_job`
|
||||
Crash-safe state одной большой публикации и путь к локальному файлу.
|
||||
|
||||
Основные состояния:
|
||||
`SNAPSHOT_CREATED → FILE_BUILT → ARWEAVE_UPLOADED → ARWEAVE_CONFIRMED → SOLANA_SUBMITTED → SOLANA_FINALIZED → CURSORS_COMMITTED`.
|
||||
|
||||
### `archive_publish_job_chain`
|
||||
Frozen range каждой blockchain текущего job + старые и новые координаты chunk. Пока job не finalized, `archive_chain_cursor` не двигается.
|
||||
|
||||
`DatabaseInitializer` автоматически применяет migration v22 при старте существующей БД. Новая БД создаётся уже со схемой v22.
|
||||
|
||||
## 3. Как считается первая дельта
|
||||
|
||||
`ArchivePublisherService.createFrozenJob()` проходит по `BlockchainStateDAO.listAll()`.
|
||||
|
||||
- Если cursor для `blockchain_name` отсутствует: `from = 0`, поэтому первый архив содержит всё локально известное состояние `0..head`.
|
||||
- Если cursor существует: `from = last_archived + 1`.
|
||||
- Перед продолжением проверяется hash cursor-блока.
|
||||
|
||||
Папка `data/archive` сама по себе **не является источником истины** о том, был ли первый архив. Источник истины — БД cursor/job. Поэтому удаление локального файла не приводит к ошибочной повторной полной публикации.
|
||||
|
||||
## 4. Локальный lifecycle файла
|
||||
|
||||
До появления Arweave TX ID:
|
||||
|
||||
`<login>.<00001>.<dd.MM.yy>.tmp.SHiNE-archive`
|
||||
|
||||
После полной успешной загрузки transaction header + chunks в Arweave:
|
||||
|
||||
`<login>.<00001>.<dd.MM.yy>.<ARWEAVE_TX_ID>.SHiNE-archive`
|
||||
|
||||
Дата — реальная дата freeze snapshot в timezone archive publisher-а. Номер начинается с `00001`. Пять цифр — минимальная ширина, а не лимит.
|
||||
|
||||
После rename файл остаётся локально. При crash после сохранения TX ID, но до rename, recovery переименует тот же файл и не загрузит его повторно.
|
||||
|
||||
## 5. User PDA block type `100`
|
||||
|
||||
Содержимое:
|
||||
|
||||
```text
|
||||
u8 block_type = 100
|
||||
u8 block_version = 0
|
||||
bytes[32] archive_tx_id
|
||||
bytes[32] archive_hash
|
||||
```
|
||||
|
||||
Используется существующий `update_user_pda`; отдельной instruction нет.
|
||||
|
||||
Совместимость:
|
||||
- legacy update без archive extension должен сохранить старый archive head;
|
||||
- новый update может заменить/очистить block `100`;
|
||||
- Java/JS codecs и PostgreSQL Solana sync умеют читать новый блок.
|
||||
|
||||
Ключевой Rust-файл: `shine-solana/shine/programs/shine_users/src/lib.rs`.
|
||||
|
||||
## 6. Startup сервера
|
||||
|
||||
`WsServer` после текущего Solana/users sync и inter-server blockchain sync вызывает `ArchivePublisherScheduler.startOrLog()`.
|
||||
|
||||
При `archive.publish.enabled=false` scheduler пишет лог о выключенной функции и больше ничего не делает. Ключи/Arweave wallet на обычном сервере тогда не требуются.
|
||||
|
||||
## 7. Legacy TestFreeAvatar
|
||||
|
||||
Старый временный `TestFreeAvatarArweaveService` больше не является частью активного WS-протокола. Registry/API документация убраны. При наложении changed-files ZIP поверх старого дерева старые исходники физически останутся, поэтому их список для удаления находится в `05_PATCH_CONTENTS_AND_REMOVALS.md`.
|
||||
|
||||
|
||||
## Trusted importer / location index / Viewer
|
||||
|
||||
Server importer:
|
||||
|
||||
```text
|
||||
shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java
|
||||
shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java
|
||||
shine-server-archive/src/main/java/server/archive/ArchiveImportService.java
|
||||
shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java
|
||||
```
|
||||
|
||||
Database:
|
||||
|
||||
```text
|
||||
shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java
|
||||
shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java
|
||||
shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java
|
||||
shine-server-db/src/main/resources/postgres/migration_v23.sql
|
||||
shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java
|
||||
```
|
||||
|
||||
`PostgresStorageRepository` сохраняет `archive_imported=true` при повторном sync того же head и автоматически сбрасывает флаг в `false`, если `archive_head_tx_id` или `archive_head_hash` изменились.
|
||||
|
||||
WS API:
|
||||
|
||||
```text
|
||||
GetArchiveBlockchainLocation
|
||||
```
|
||||
|
||||
UI:
|
||||
|
||||
```text
|
||||
shine-UI/js/pages/blockchain-archive-view.js
|
||||
shine-UI/Blockchain-Viewer.html
|
||||
```
|
||||
|
||||
`Blockchain-Viewer.html` получает `tx + offset + size + blockchain`, идёт назад по `PreviousBlockchainChunkRef` и использует существующий parser каналов старого Viewer-а.
|
||||
@@ -1,466 +0,0 @@
|
||||
# Деплой SHiNE Archive Publisher v1.0 на тестовый сервер
|
||||
|
||||
Документ рассчитан на человека или автономного coding/deploy агента. Выполнять шаги по порядку. Не включать publisher до проверки Solana-программы и ключей.
|
||||
|
||||
## 0. Что именно меняется
|
||||
|
||||
Нужны изменения одновременно в:
|
||||
|
||||
1. серверном Java-коде;
|
||||
2. PostgreSQL schema v23;
|
||||
3. Solana-программе `shine_users` (PDA block type `100` + backward-compatible update parser);
|
||||
4. Java/JS User PDA codecs.
|
||||
|
||||
**Критично:** новый серверный writer отправляет расширенный обычный `update_user_pda`. Если в целевом кластере работает старая `shine_users`, включать publisher нельзя.
|
||||
|
||||
---
|
||||
|
||||
# 1. Применить пакет к исходникам
|
||||
|
||||
ZIP из этой поставки содержит только новые/изменённые файлы с путями относительно корня репозитория.
|
||||
|
||||
Сделать backup текущего проекта, затем распаковать ZIP поверх рабочего дерева.
|
||||
|
||||
После распаковки удалить legacy-файлы из списка `05_PATCH_CONTENTS_AND_REMOVALS.md`.
|
||||
|
||||
Проверить:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
или, если это не git checkout, сравнить список файлов с manifest из той же документации.
|
||||
|
||||
---
|
||||
|
||||
# 2. Обязательно обновить `shine_users` в нужном Solana-кластере
|
||||
|
||||
## 2.1. Проверить целевой кластер
|
||||
|
||||
Не деплоить вслепую. Сначала:
|
||||
|
||||
```bash
|
||||
solana config get
|
||||
```
|
||||
|
||||
и проверить RPC/кластер, upgrade authority и Program ID. В проекте `shine_users` использует Program ID:
|
||||
|
||||
```text
|
||||
SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6
|
||||
```
|
||||
|
||||
Если тестовый сервер использует mainnet RPC, обновляется именно mainnet-программа. Если тестовый контур использует devnet — сначала убедиться, что программа с нужным ID действительно существует в devnet.
|
||||
|
||||
## 2.2. Собрать Solana program
|
||||
|
||||
```bash
|
||||
cd shine-solana/shine
|
||||
anchor build
|
||||
```
|
||||
|
||||
Минимальная host-проверка Rust, если Anchor/SBF toolchain временно недоступен:
|
||||
|
||||
```bash
|
||||
cargo build -p shine_users
|
||||
```
|
||||
|
||||
Но для реального deploy нужен SBF/Anchor build.
|
||||
|
||||
## 2.3. Обновить существующую программу
|
||||
|
||||
Использовать существующий project deploy/upgrade authority. Типовой вариант:
|
||||
|
||||
```bash
|
||||
solana program deploy target/deploy/shine_users.so \
|
||||
--program-id target/deploy/shine_users-keypair.json \
|
||||
--upgrade-authority /PATH/TO/UPGRADE_AUTHORITY.json \
|
||||
--url <TARGET_RPC_URL>
|
||||
```
|
||||
|
||||
Если в проекте используется рабочий Anchor deploy workflow, допустимо использовать его вместо прямого `solana program deploy`; главное — сохранить тот же Program ID.
|
||||
|
||||
После обновления:
|
||||
|
||||
```bash
|
||||
solana program show SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6 --url <TARGET_RPC_URL>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3. Собрать серверный JAR
|
||||
|
||||
Из корня репозитория:
|
||||
|
||||
```bash
|
||||
./gradlew clean shadowJar
|
||||
```
|
||||
|
||||
Ожидаемый файл:
|
||||
|
||||
```text
|
||||
SHiNE-server/build/libs/shine-server.jar
|
||||
```
|
||||
|
||||
Если Gradle wrapper не может скачать зависимости, сборку выполнять на машине/CI с доступом к Maven/Gradle или с уже заполненным cache.
|
||||
|
||||
---
|
||||
|
||||
# 4. Подготовить PostgreSQL backup
|
||||
|
||||
Перед первым стартом версии со schema v23 сделать backup тестовой БД. Например:
|
||||
|
||||
```bash
|
||||
pg_dump -Fc -d '<DATABASE_URL_OR_NAME>' -f shine-before-archive-v22.dump
|
||||
```
|
||||
|
||||
Точная команда зависит от текущей схемы доступа PostgreSQL.
|
||||
|
||||
При старте сервер последовательно применит `migration_v22.sql` и `migration_v23.sql`, если это требуется текущей версии БД. Вручную migrations выполнять обычно не нужно.
|
||||
|
||||
После старта проверить:
|
||||
|
||||
```sql
|
||||
SELECT * FROM db_schema_version WHERE id=1;
|
||||
```
|
||||
|
||||
Ожидается:
|
||||
|
||||
```text
|
||||
schema_version = 23
|
||||
```
|
||||
|
||||
И наличие:
|
||||
|
||||
```sql
|
||||
SELECT to_regclass('public.archive_chain_cursor');
|
||||
SELECT to_regclass('public.archive_publish_job');
|
||||
SELECT to_regclass('public.archive_publish_job_chain');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 5. Подготовить секреты на тестовом сервере
|
||||
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
sudo -u player mkdir -p /home/player/SHiNE/secrets
|
||||
sudo chmod 700 /home/player/SHiNE/secrets
|
||||
```
|
||||
|
||||
Положить:
|
||||
|
||||
```text
|
||||
/home/player/SHiNE/secrets/archive-arweave-wallet.json
|
||||
/home/player/SHiNE/secrets/server-root.key
|
||||
/home/player/SHiNE/secrets/server-client.key
|
||||
```
|
||||
|
||||
Права:
|
||||
|
||||
```bash
|
||||
sudo chown player:player /home/player/SHiNE/secrets/*
|
||||
sudo chmod 600 /home/player/SHiNE/secrets/*
|
||||
```
|
||||
|
||||
### Форматы root/client key
|
||||
|
||||
Поддерживаются:
|
||||
- raw seed 32 bytes;
|
||||
- raw keypair 64 bytes;
|
||||
- Solana JSON array на 32/64 байта;
|
||||
- Base58 seed на 32 байта или Solana secret key на 64 байта;
|
||||
- Base64 raw/PKCS8, где seed извлекается из последних 32 bytes.
|
||||
|
||||
### Arweave wallet
|
||||
|
||||
Ожидается RSA JWK с полями `n,e,d,p,q,dp,dq,qi`. Кошелёк должен иметь достаточно AR для размера первого полного архива.
|
||||
|
||||
---
|
||||
|
||||
# 6. Настроить внешний `application.properties`
|
||||
|
||||
Сервер читает внешний `application.properties` из **WorkingDirectory процесса** и накладывает его поверх встроенного конфига. Сохранять существующие DB/Solana/server параметры и добавить archive-секцию.
|
||||
|
||||
Минимум:
|
||||
|
||||
```properties
|
||||
archive.publish.enabled=true
|
||||
archive.publish.time=00:00
|
||||
archive.publish.zoneId=Europe/Warsaw
|
||||
archive.workDir=data/archive
|
||||
archive.maxFileBytes=4000000000
|
||||
|
||||
archive.arweave.gateway=https://arweave.net
|
||||
archive.arweave.walletJwkPath=/home/player/SHiNE/secrets/archive-arweave-wallet.json
|
||||
archive.arweave.minConfirmations=1
|
||||
archive.arweave.confirmPollSeconds=30
|
||||
archive.arweave.confirmTimeoutMinutes=180
|
||||
|
||||
archive.solana.rootKeyPath=/home/player/SHiNE/secrets/server-root.key
|
||||
archive.solana.clientKeyPath=/home/player/SHiNE/secrets/server-client.key
|
||||
archive.solana.confirmPollSeconds=5
|
||||
archive.solana.confirmTimeoutMinutes=30
|
||||
archive.solana.commitment=finalized
|
||||
```
|
||||
|
||||
`archive.publish.zoneId` выбрать осознанно. Если оставить пустым, используется timezone JVM/машины. Для ежедневного запуска ровно в нужную локальную полночь лучше задать ZoneId явно.
|
||||
|
||||
### Solana RPC
|
||||
|
||||
**Отдельного archive RPC нет.** Writer использует:
|
||||
|
||||
1. `solana.users.sync.rpcUrl`, если он задан;
|
||||
2. иначе `solana.rpcUrl`.
|
||||
|
||||
Поэтому существующий рабочий RPC не дублировать в archive settings.
|
||||
|
||||
---
|
||||
|
||||
# 7. Убедиться, что server login соответствует ключам
|
||||
|
||||
`server.SHiNE.login` должен быть тем User PDA, чей archive head будет обновляться.
|
||||
|
||||
На startup `SolanaArchiveHeadWriter` проверяет:
|
||||
|
||||
```text
|
||||
derive(root private) == UserPDA.root_key
|
||||
derive(client private) == UserPDA.client_key
|
||||
```
|
||||
|
||||
При несовпадении archive publisher не стартует.
|
||||
|
||||
---
|
||||
|
||||
# 8. Развернуть JAR
|
||||
|
||||
Можно использовать существующий `deploy/scripts/deploy_server.sh`. Он:
|
||||
|
||||
- собирает `shadowJar`;
|
||||
- копирует JAR;
|
||||
- создаёт/обновляет systemd unit;
|
||||
- перезапускает сервис.
|
||||
|
||||
Типовой запуск задаётся уже существующими переменными проекта. Либо вручную заменить `shine-server.jar` в рабочей директории и перезапустить systemd service.
|
||||
|
||||
После deploy убедиться, что WorkingDirectory содержит внешний `application.properties`.
|
||||
|
||||
---
|
||||
|
||||
# 9. Первый startup
|
||||
|
||||
Смотреть лог:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u <SERVICE_NAME> -f
|
||||
```
|
||||
|
||||
Ожидаемые события:
|
||||
|
||||
1. DB migration до v22;
|
||||
2. обычный Solana users sync;
|
||||
3. обычный server-to-server blockchain sync;
|
||||
4. archive publisher preflight;
|
||||
5. строка примерно:
|
||||
|
||||
```text
|
||||
Archive publisher включён: login=... dir=data/archive dailyAt=00:00 zone=...
|
||||
Следующая архивная публикация запланирована на ...
|
||||
```
|
||||
|
||||
Если остался незавершённый job, он будет продолжен **сразу после старта**, не ожидая полуночи. Новый snapshot создаётся только по расписанию.
|
||||
|
||||
---
|
||||
|
||||
# 10. Что произойдёт в первую полночь
|
||||
|
||||
Если `archive_chain_cursor` пуст:
|
||||
|
||||
- сервер проходит все локально известные `blockchain_name`;
|
||||
- для каждой берёт range `0..current_head`;
|
||||
- создаёт первый big block `#1`;
|
||||
- для каждой blockchain создаёт максимум один `UserBlockchainChunk`;
|
||||
- внутри chunk лежат все её raw records из frozen range;
|
||||
- backlink первого chunk пустой (`previous_big_block_ref = 0xFFFFFFFF`);
|
||||
- создаёт локальный файл, например:
|
||||
|
||||
```text
|
||||
data/archive/archive01.00001.12.09.26.tmp.SHiNE-archive
|
||||
```
|
||||
|
||||
- загружает его в Arweave;
|
||||
- после успешной загрузки переименовывает тот же файл, например:
|
||||
|
||||
```text
|
||||
data/archive/archive01.00001.12.09.26.<REAL_TX_ID>.SHiNE-archive
|
||||
```
|
||||
|
||||
- ждёт confirmations;
|
||||
- обычным `update_user_pda` записывает block type `100`;
|
||||
- ждёт Solana `finalized`;
|
||||
- только затем commit-ит cursors.
|
||||
|
||||
Если новых данных нет, пустой big block не создаётся.
|
||||
|
||||
---
|
||||
|
||||
# 11. Проверка результата
|
||||
|
||||
## Локальные файлы
|
||||
|
||||
```bash
|
||||
ls -lah data/archive/
|
||||
```
|
||||
|
||||
После успешного upload `.tmp.SHiNE-archive` для завершённого job оставаться не должен; должен быть файл с реальным TX ID в имени.
|
||||
|
||||
## Job DB
|
||||
|
||||
```sql
|
||||
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
|
||||
arweave_confirmations, solana_signature, error_text
|
||||
FROM archive_publish_job
|
||||
ORDER BY id DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
Успех:
|
||||
|
||||
```text
|
||||
status = CURSORS_COMMITTED
|
||||
```
|
||||
|
||||
## Cursors
|
||||
|
||||
```sql
|
||||
SELECT blockchain_name,
|
||||
last_archived_source_block_number,
|
||||
last_archive_big_block_number,
|
||||
last_chunk_offset,
|
||||
last_chunk_size
|
||||
FROM archive_chain_cursor
|
||||
ORDER BY blockchain_name;
|
||||
```
|
||||
|
||||
## Локальная проекция User PDA
|
||||
|
||||
После очередной Solana sync:
|
||||
|
||||
```sql
|
||||
SELECT login, record_number, archive_head_tx_id, archive_head_hash
|
||||
FROM solana_user_pda_current
|
||||
WHERE login = '<SERVER_LOGIN>';
|
||||
```
|
||||
|
||||
`archive_head_tx_id` и `archive_head_hash` должны быть непустыми.
|
||||
|
||||
## Arweave
|
||||
|
||||
TX берётся прямо из имени финального файла. Проверить:
|
||||
|
||||
```bash
|
||||
curl -sS 'https://arweave.net/tx/<TX_ID>/status'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 12. Быстрый тест до полуночи
|
||||
|
||||
Если не хочется ждать 00:00, на тестовом сервере временно установить `archive.publish.time` на ближайшие 5–10 минут в будущем в выбранной `archive.publish.zoneId`, затем перезапустить сервис.
|
||||
|
||||
После проверки вернуть:
|
||||
|
||||
```properties
|
||||
archive.publish.time=00:00
|
||||
```
|
||||
|
||||
Не использовать интервал в минутах: scheduler специально работает по календарному локальному времени один раз в сутки.
|
||||
|
||||
---
|
||||
|
||||
# 13. Откат / выключение
|
||||
|
||||
Самый безопасный функциональный rollback:
|
||||
|
||||
```properties
|
||||
archive.publish.enabled=false
|
||||
```
|
||||
|
||||
и рестарт сервера. Тогда обычная серверная работа продолжается, archive scheduler ничего не публикует.
|
||||
|
||||
Не удалять `archive_chain_cursor`/job таблицы без причины: они нужны, чтобы после повторного включения publisher продолжил дельту, а не загрузил всю историю заново.
|
||||
|
||||
Уже опубликованные Arweave данные являются постоянными и обычным rollback сервера не удаляются.
|
||||
|
||||
---
|
||||
|
||||
# 14. Наиболее вероятные ошибки
|
||||
|
||||
### `Root key archive publisher-а не совпадает с User PDA`
|
||||
Положен неправильный root key или неверный `server.SHiNE.login`.
|
||||
|
||||
### `Client key archive publisher-а не совпадает с User PDA`
|
||||
Неверный client key.
|
||||
|
||||
### `Недостаточно AR`
|
||||
Пополнить Arweave wallet. Первый архив может быть существенно больше ежедневных дельт.
|
||||
|
||||
### Arweave upload прошёл, Solana update не прошёл
|
||||
Не удалять локальный файл/job. После исправления RPC/program/key причины restart продолжит незавершённый job.
|
||||
|
||||
### `archive cursor hash не совпадает`
|
||||
Локальная blockchain изменилась относительно уже зафиксированного cursor. Не форсировать публикацию; сначала разобраться с resync/fork.
|
||||
|
||||
### Старый `shine_users`
|
||||
Если новый update payload отклоняется программой, проверить, что целевая Solana `shine_users` действительно обновлена этой версией.
|
||||
|
||||
|
||||
## Trusted archive importer
|
||||
|
||||
На обычном тестовом сервере publisher можно оставить выключенным, но разрешить импорт от конкретного архиватора:
|
||||
|
||||
```properties
|
||||
archive.publish.enabled=false
|
||||
archive.import.allowedPublishers=<LOGIN_ARCHIVE_SERVER>
|
||||
archive.import.intervalMinutes=60
|
||||
archive.import.workDir=data/archive-import
|
||||
```
|
||||
|
||||
Несколько логинов:
|
||||
|
||||
```properties
|
||||
archive.import.allowedPublishers=server-a,server-b
|
||||
```
|
||||
|
||||
Пустая строка означает, что importer не запускается.
|
||||
|
||||
После старта проверить логи:
|
||||
|
||||
```text
|
||||
Archive importer включён. approvedPublishers=...
|
||||
```
|
||||
|
||||
Если сервер подключается к publisher впервые, importer скачает head, прочитает FULL reference table и обработает все ещё не известные big blocks от старых к новым.
|
||||
|
||||
Проверка БД:
|
||||
|
||||
```sql
|
||||
SELECT login, archive_head_tx_id, archive_imported, archive_last_imported_tx_id
|
||||
FROM solana_user_pda_current
|
||||
WHERE is_server=TRUE AND archive_head_tx_id<>''
|
||||
ORDER BY login;
|
||||
|
||||
SELECT blockchain_name, publisher_login, arweave_tx_id,
|
||||
big_block_number, chunk_offset, chunk_size, source_last_block_number
|
||||
FROM archive_blockchain_location
|
||||
ORDER BY updated_at_ms DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
После деплоя UI файл должен быть доступен по:
|
||||
|
||||
```text
|
||||
https://<UI_HOST>/Blockchain-Viewer.html
|
||||
```
|
||||
|
||||
В приложении: `Настройки → Архив блокчейна`.
|
||||
@@ -1,113 +0,0 @@
|
||||
# Проверка и эксплуатация Archive Publisher
|
||||
|
||||
## Перед ночным тестом
|
||||
|
||||
- [ ] Новый `shine_users` уже развёрнут на том Solana-кластере, который использует сервер.
|
||||
- [ ] Серверный JAR собран из этого пакета.
|
||||
- [ ] БД забэкаплена.
|
||||
- [ ] `archive.publish.enabled=true`.
|
||||
- [ ] `archive.publish.time=00:00`.
|
||||
- [ ] `archive.publish.zoneId` соответствует желаемой локальной полуночи.
|
||||
- [ ] `server.SHiNE.login` соответствует root/client keys.
|
||||
- [ ] Arweave JWK читается пользователем процесса.
|
||||
- [ ] На Arweave wallet достаточно AR.
|
||||
- [ ] `data/archive` доступна на запись.
|
||||
- [ ] В логе есть `Archive publisher включён` и точное время следующего запуска.
|
||||
|
||||
## Во время job
|
||||
|
||||
Нормальная последовательность логов/статусов:
|
||||
|
||||
```text
|
||||
SNAPSHOT_CREATED
|
||||
FILE_BUILT
|
||||
ARWEAVE_UPLOADED
|
||||
ARWEAVE_CONFIRMED
|
||||
SOLANA_SUBMITTED
|
||||
SOLANA_FINALIZED
|
||||
CURSORS_COMMITTED
|
||||
```
|
||||
|
||||
После `FILE_BUILT` существует `.tmp.SHiNE-archive`.
|
||||
После полного Arweave upload имя уже содержит настоящий TX ID.
|
||||
|
||||
## После успешного первого job
|
||||
|
||||
Проверить:
|
||||
|
||||
```bash
|
||||
find data/archive -maxdepth 1 -type f -name '*.SHiNE-archive' -ls
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT big_block_number, status, local_archive_path, arweave_confirmations
|
||||
FROM archive_publish_job ORDER BY id DESC LIMIT 1;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT count(*) AS archived_blockchains FROM archive_chain_cursor;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT login, archive_head_tx_id, archive_head_hash
|
||||
FROM solana_user_pda_current
|
||||
WHERE login='<SERVER_LOGIN>';
|
||||
```
|
||||
|
||||
## Проверка второй публикации
|
||||
|
||||
До следующей полуночи добавить несколько новых SHiNE records только в часть blockchain. После следующего job:
|
||||
|
||||
- в новый big block должны попасть только изменившиеся blockchain;
|
||||
- одна blockchain в новом big block должна иметь один chunk независимо от числа новых records;
|
||||
- cursor blockchain, которая не изменилась, должен остаться на старом big block/chunk;
|
||||
- backlink изменившегося chunk должен указывать на предыдущий chunk этой же blockchain;
|
||||
- FULL reference table нового big block должна содержать все предыдущие finalized big blocks.
|
||||
|
||||
## Crash/restart сценарии
|
||||
|
||||
### Restart после FILE_BUILT
|
||||
Должен использоваться тот же frozen job и тот же локальный файл.
|
||||
|
||||
### Restart после ARWEAVE_UPLOADED
|
||||
Не должно быть повторной оплаты/upload. Если TX сохранён, но rename не успел произойти, recovery переименует `.tmp` в имя с TX ID.
|
||||
|
||||
### Restart после SOLANA_FINALIZED
|
||||
При совпадении PDA head с job сервер должен только commit cursors.
|
||||
|
||||
## Обычный сервер без публикации
|
||||
|
||||
Проверить отдельно:
|
||||
|
||||
```properties
|
||||
archive.publish.enabled=false
|
||||
```
|
||||
|
||||
Сервер должен запускаться без Arweave/root/client archive key files и не создавать `archive_publish_job`.
|
||||
|
||||
|
||||
## Проверка trusted importer
|
||||
|
||||
1. На принимающем сервере указать только тестовый publisher:
|
||||
|
||||
```properties
|
||||
archive.import.allowedPublishers=<publisher-login>
|
||||
```
|
||||
|
||||
2. Перезапустить сервер.
|
||||
3. Дождаться Solana PDA sync и цикла importer-а.
|
||||
4. Проверить, что у publisher в `solana_user_pda_current` после успешного цикла `archive_imported=true`, а `archive_last_imported_tx_id=archive_head_tx_id`.
|
||||
5. Проверить `archive_blockchain_location`.
|
||||
6. Для blockchain, которой локально не хватало блоков, убедиться, что `blockchain_state.last_block_number` вырос.
|
||||
7. Для уже существующих блоков importer должен пропускать совпадающий hash, а не создавать дубликат.
|
||||
8. Временно удалить publisher из whitelist и убедиться, что новые archive heads больше не скачиваются.
|
||||
|
||||
### Проверка Viewer
|
||||
|
||||
Открыть `Настройки → Архив блокчейна`, получить ссылку и проверить:
|
||||
|
||||
- `tx`, `offset`, `size`, `blockchain` присутствуют;
|
||||
- Viewer собирает несколько chunks по backlink;
|
||||
- неправильный `blockchain` в URL приводит к ошибке проверки;
|
||||
- `channel` открывает нужный канал;
|
||||
- `message` прокручивает к нужному block number.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Состав текущего patch-пакета
|
||||
|
||||
Этот этап рассчитан **поверх последнего рабочего ZIP**, присланного после успешного запуска archive publisher.
|
||||
|
||||
Пакет этого этапа добавляет:
|
||||
|
||||
- trusted archive importer;
|
||||
- whitelist publisher-ов через настройки сервера;
|
||||
- строгую проверку больших `SHINE-ARCHIVE`;
|
||||
- импорт недостающих raw SHiNE blocks через обычный validator `AddBlock`;
|
||||
- локальные поля состояния импорта в `solana_user_pda_current` и таблицу `archive_blockchain_location`;
|
||||
- schema migration v23;
|
||||
- WS API `GetArchiveBlockchainLocation`;
|
||||
- экран `Настройки → Архив блокчейна`;
|
||||
- `shine-UI/Blockchain-Viewer.html`;
|
||||
- документацию importer/viewer.
|
||||
|
||||
## Удаления
|
||||
|
||||
В **этом** обновлении удалять файлы не требуется.
|
||||
|
||||
Старые test/free-avatar исходники, если они всё ещё физически присутствуют в рабочем дереве, этим patch-пакетом не затрагиваются. Они не относятся к trusted archive importer и не должны удаляться автоматически при наложении этого обновления.
|
||||
|
||||
## Как накладывать ZIP changed-files
|
||||
|
||||
ZIP содержит только новые/изменённые файлы с путями от корня репозитория.
|
||||
|
||||
Распаковать поверх той рабочей версии, из которой сделан пакет, с заменой совпадающих файлов.
|
||||
|
||||
После наложения:
|
||||
|
||||
```bash
|
||||
./gradlew shadowJar
|
||||
```
|
||||
|
||||
Если Gradle wrapper ещё не установлен локально, сначала обеспечить доступ к уже используемой версии Gradle/кэшу.
|
||||
|
||||
При старте сервер сам должен поднять schema с v22 до v23.
|
||||
@@ -1,50 +0,0 @@
|
||||
# Manifest changed/new files
|
||||
|
||||
Основа сравнения: последний присланный рабочий ZIP `3d14e34c-4249-4e11-8042-3f3349c8e9fd.zip`.
|
||||
|
||||
- Изменённых файлов: 19
|
||||
- Новых файлов: 14
|
||||
- Удаляемых файлов: 0
|
||||
|
||||
## Изменённые файлы
|
||||
|
||||
- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java`
|
||||
- `SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java`
|
||||
- `SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql`
|
||||
- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java`
|
||||
- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java`
|
||||
- `SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java`
|
||||
- `SHiNE-server/src/main/java/server/ws/WsServer.java`
|
||||
- `SHiNE-server/src/main/resources/application.properties`
|
||||
- `docs/Archive/01_PROTOCOL_v1.0.md`
|
||||
- `docs/Archive/02_IMPLEMENTATION_MAP.md`
|
||||
- `docs/Archive/03_DEPLOY_TEST_SERVER.md`
|
||||
- `docs/Archive/04_TEST_AND_OPERATIONS.md`
|
||||
- `docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md`
|
||||
- `docs/Archive/06_FILE_MANIFEST.md`
|
||||
- `docs/Archive/README.md`
|
||||
- `docs/Archive/archive-publisher.example.properties`
|
||||
- `shine-UI/js/app.js`
|
||||
- `shine-UI/js/pages/settings-view.js`
|
||||
- `shine-UI/js/services/auth-service.js`
|
||||
|
||||
## Новые файлы
|
||||
|
||||
- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java`
|
||||
- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java`
|
||||
- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java`
|
||||
- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java`
|
||||
- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java`
|
||||
- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java`
|
||||
- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java`
|
||||
- `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql`
|
||||
- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java`
|
||||
- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java`
|
||||
- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java`
|
||||
- `docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md`
|
||||
- `shine-UI/Blockchain-Viewer.html`
|
||||
- `shine-UI/js/pages/blockchain-archive-view.js`
|
||||
|
||||
## Удаления
|
||||
|
||||
На этом этапе файлов для удаления нет.
|
||||
@@ -1,303 +0,0 @@
|
||||
# Импорт доверенных SHINE-ARCHIVE и Blockchain Viewer
|
||||
|
||||
Этот документ описывает вторую половину архивной системы: как обычный SHiNE-сервер узнаёт о новых archive head других серверов, кому доверяет, как импортирует недостающие SHiNE-блоки и как UI получает ссылку на историю конкретной `blockchain_name`.
|
||||
|
||||
## 1. Источник archive head
|
||||
|
||||
Archive importer **не делает отдельные Solana RPC-запросы**.
|
||||
|
||||
Уже существующий Solana Users Sync разбирает User PDA block type `100` и копирует его в локальную таблицу:
|
||||
|
||||
```text
|
||||
solana_user_pda_current.archive_head_tx_id
|
||||
solana_user_pda_current.archive_head_hash
|
||||
```
|
||||
|
||||
Для локального состояния importer v23 добавляет туда же:
|
||||
|
||||
```text
|
||||
archive_imported BOOLEAN
|
||||
archive_last_imported_tx_id TEXT
|
||||
```
|
||||
|
||||
Это локальные поля сервера, в Solana они не записываются.
|
||||
|
||||
Когда обычный Solana Users Sync видит тот же archive head повторно, `archive_imported` сохраняется как есть.
|
||||
|
||||
Когда `archive_head_tx_id` или `archive_head_hash` изменился:
|
||||
|
||||
```text
|
||||
archive_imported = false
|
||||
```
|
||||
|
||||
а `archive_last_imported_tx_id` сохраняет последнюю успешно обработанную точку и позволяет продолжить после сбоя.
|
||||
|
||||
## 2. Whitelist доверенных publisher-ов
|
||||
|
||||
В `application.properties` задаётся список логинов серверов, архивы которых разрешено принимать:
|
||||
|
||||
```properties
|
||||
archive.import.allowedPublishers=archive-server-1,archive-server-2
|
||||
archive.import.intervalMinutes=60
|
||||
archive.import.workDir=data/archive-import
|
||||
```
|
||||
|
||||
Правила:
|
||||
|
||||
- логины разделяются запятыми;
|
||||
- сравнение без учёта регистра;
|
||||
- пустое `archive.import.allowedPublishers=` полностью выключает importer;
|
||||
- принимаются только строки `solana_user_pda_current` с `is_server=true`;
|
||||
- whitelist является только первым фильтром, криптографические проверки всё равно обязательны.
|
||||
|
||||
## 3. Периодическая проверка
|
||||
|
||||
После запуска сервера importer делает первую проверку примерно через 10 секунд, затем по умолчанию раз в 60 минут.
|
||||
|
||||
Каждый цикл — дешёвый запрос только к локальной PostgreSQL:
|
||||
|
||||
```text
|
||||
approved publisher
|
||||
AND is_server=true
|
||||
AND archive_head_tx_id != ''
|
||||
AND archive_imported=false
|
||||
```
|
||||
|
||||
Если таких строк нет, Arweave не вызывается.
|
||||
|
||||
## 4. Проверки archive block
|
||||
|
||||
Для каждого pending publisher сервер проверяет:
|
||||
|
||||
1. publisher находится в whitelist;
|
||||
2. `archive_head_tx_id/archive_head_hash` уже пришли через обычный User PDA sync;
|
||||
3. SHA-256 скачанного файла совпадает с `archive_head_hash`;
|
||||
4. `creator_login == closer_login == publisher login`;
|
||||
5. Ed25519 archive signature проверяется root key publisher-а из User PDA;
|
||||
6. каждый вложенный raw SHiNE block проходит обычную SHiNE-проверку через существующий `AddBlock` path.
|
||||
|
||||
Подпись большого архива защищает контейнер и навигацию, а подписи обычных SHiNE blocks защищают сами пользовательские данные.
|
||||
|
||||
## 5. Как определяется, что head новый
|
||||
|
||||
Отдельная таблица обработанных TX для основной логики не нужна.
|
||||
|
||||
Текущий User PDA snapshot уже содержит:
|
||||
|
||||
```text
|
||||
archive_head_tx_id
|
||||
archive_head_hash
|
||||
archive_imported
|
||||
archive_last_imported_tx_id
|
||||
```
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
archive_head_tx_id = TX100
|
||||
archive_imported = false
|
||||
archive_last_imported_tx_id = TX97
|
||||
```
|
||||
|
||||
Это означает: Solana уже объявила `TX100` текущей головой publisher-а, но локальный сервер успел импортировать только до `TX97`.
|
||||
|
||||
После полной успешной обработки `TX100`:
|
||||
|
||||
```text
|
||||
archive_imported = true
|
||||
archive_last_imported_tx_id = TX100
|
||||
```
|
||||
|
||||
При следующем новом PDA head Users Sync сам сбросит `archive_imported=false`.
|
||||
|
||||
## 6. Догон пропущенных больших блоков
|
||||
|
||||
Если сервер был выключен и вместо `TX97` сразу увидел `TX100`, он скачивает и проверяет `TX100`, читает его FULL reference table и находит `TX97`.
|
||||
|
||||
После этого импортирует только:
|
||||
|
||||
```text
|
||||
TX98
|
||||
TX99
|
||||
TX100
|
||||
```
|
||||
|
||||
После каждого полностью импортированного большого блока `archive_last_imported_tx_id` сдвигается вперёд.
|
||||
|
||||
Если сервер впервые видит publisher и `archive_last_imported_tx_id` пустой, импортируются все previous refs от старых к новым, затем текущий head.
|
||||
|
||||
Если непустой `archive_last_imported_tx_id` отсутствует в FULL history текущего head, importer останавливается: это рассматривается как возможная смена/fork archive chain, а не как повод молча забыть старый cursor.
|
||||
|
||||
## 7. Crash recovery
|
||||
|
||||
Если процесс упал после `TX98`, но до `TX100`:
|
||||
|
||||
```text
|
||||
archive_imported = false
|
||||
archive_last_imported_tx_id = TX98
|
||||
```
|
||||
|
||||
Следующий часовой цикл продолжит с `TX99`.
|
||||
|
||||
Если процесс успел импортировать head и записать `archive_last_imported_tx_id = TX100`, но упал до установки `archive_imported=true`, следующий цикл просто завершит отметку без повторной загрузки всей цепочки.
|
||||
|
||||
Все cursor updates выполняются условно по ожидаемому `archive_head_tx_id`. Если обычный Solana Users Sync успел заменить head во время импорта, старый процесс не сможет пометить новый head импортированным.
|
||||
|
||||
## 8. Импорт `UserBlockchainChunk`
|
||||
|
||||
Для каждого chunk:
|
||||
|
||||
1. берётся lock этой `blockchain_name`;
|
||||
2. если локального `blockchain_state` нет, identity создаётся по синхронизированному User PDA;
|
||||
3. raw records разбираются как обычные `BchBlockEntry`;
|
||||
4. уже существующий block допускается только при совпадении hash;
|
||||
5. новый block должен идти строго `localLast + 1`;
|
||||
6. новый block добавляется существующим validator/write path;
|
||||
7. конфликт hash или gap останавливает импорт этой archive chain.
|
||||
|
||||
## 9. Индекс последнего archive chunk
|
||||
|
||||
Таблица:
|
||||
|
||||
```text
|
||||
archive_blockchain_location
|
||||
```
|
||||
|
||||
содержит для каждой `blockchain_name`:
|
||||
|
||||
```text
|
||||
blockchain_name
|
||||
publisher_login
|
||||
arweave_tx_id
|
||||
archive_hash
|
||||
big_block_number
|
||||
chunk_offset
|
||||
chunk_size
|
||||
source_last_block_number
|
||||
updated_at_ms
|
||||
```
|
||||
|
||||
Если blockchain встретилась в новом archive block, её location обновляется. Если не встретилась — старая ссылка остаётся.
|
||||
|
||||
Эту таблицу заполняют как trusted importer, так и локальный archive publisher.
|
||||
|
||||
## 10. API для UI
|
||||
|
||||
WS operation:
|
||||
|
||||
```text
|
||||
GetArchiveBlockchainLocation
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetArchiveBlockchainLocation",
|
||||
"blockchainName": "alice-001"
|
||||
}
|
||||
```
|
||||
|
||||
Response содержит:
|
||||
|
||||
```text
|
||||
blockchainName
|
||||
publisherLogin
|
||||
arweaveTxId
|
||||
archiveHash
|
||||
bigBlockNumber
|
||||
chunkOffset
|
||||
chunkSize
|
||||
sourceLastBlockNumber
|
||||
```
|
||||
|
||||
## 11. UI и ссылка Viewer
|
||||
|
||||
В настройках пользователя есть экран `Архив блокчейна`.
|
||||
|
||||
Viewer-файл:
|
||||
|
||||
```text
|
||||
shine-UI/Blockchain-Viewer.html
|
||||
```
|
||||
|
||||
Основные параметры ссылки:
|
||||
|
||||
```text
|
||||
/Blockchain-Viewer.html
|
||||
?tx=<ARWEAVE_TX_ID>
|
||||
&offset=<CHUNK_OFFSET>
|
||||
&size=<CHUNK_SIZE>
|
||||
&blockchain=<BLOCKCHAIN_NAME>
|
||||
```
|
||||
|
||||
Дополнительно:
|
||||
|
||||
```text
|
||||
&channel=<CHANNEL_NAME>
|
||||
&message=<BLOCK_NUMBER>
|
||||
```
|
||||
|
||||
`blockchain` используется также для проверки: если загруженный chunk имеет другое имя blockchain, Viewer прекращает обработку.
|
||||
|
||||
`channel` открывает нужный канал, а `message` прокручивает к указанному сообщению/block number и выделяет его.
|
||||
|
||||
## 12. Как Viewer собирает всю цепочку
|
||||
|
||||
Viewer начинает с последнего `TX + offset + size`:
|
||||
|
||||
```text
|
||||
последний UserBlockchainChunk
|
||||
↓
|
||||
PreviousBlockchainChunkRef
|
||||
↓
|
||||
FULL reference table текущего big block
|
||||
↓
|
||||
TX предыдущего big block
|
||||
↓
|
||||
Range предыдущего chunk
|
||||
↓
|
||||
следующий backlink
|
||||
↓
|
||||
до NO_REFERENCE
|
||||
```
|
||||
|
||||
Чужие chunks скачивать не требуется.
|
||||
|
||||
## 13. Минимальная настройка принимающего сервера
|
||||
|
||||
```properties
|
||||
archive.publish.enabled=false
|
||||
archive.import.allowedPublishers=server-a,server-b
|
||||
archive.import.intervalMinutes=60
|
||||
archive.import.workDir=data/archive-import
|
||||
```
|
||||
|
||||
Если импорт архивов не нужен:
|
||||
|
||||
```properties
|
||||
archive.import.allowedPublishers=
|
||||
```
|
||||
|
||||
Тогда importer вообще не запускается.
|
||||
|
||||
## 14. Диагностика PostgreSQL
|
||||
|
||||
Pending archive heads:
|
||||
|
||||
```sql
|
||||
SELECT login, archive_head_tx_id, archive_imported, archive_last_imported_tx_id
|
||||
FROM solana_user_pda_current
|
||||
WHERE is_server = TRUE
|
||||
AND archive_head_tx_id <> ''
|
||||
ORDER BY login;
|
||||
```
|
||||
|
||||
Последние известные пользовательские chunks:
|
||||
|
||||
```sql
|
||||
SELECT blockchain_name, publisher_login, arweave_tx_id,
|
||||
big_block_number, chunk_offset, chunk_size, source_last_block_number
|
||||
FROM archive_blockchain_location
|
||||
ORDER BY updated_at_ms DESC;
|
||||
```
|
||||
@@ -1,42 +0,0 @@
|
||||
# SHiNE Archive Publisher — документация
|
||||
|
||||
Эта папка — **актуальная точка входа** для механизма серверной архивации SHiNE в Arweave с фиксацией archive head в Solana User PDA.
|
||||
|
||||
Если задачу выполняет другая нейронка/агент, читать документы нужно в таком порядке:
|
||||
|
||||
1. `01_PROTOCOL_v1.0.md` — бинарный формат `SHINE-ARCHIVE`, big-block references, `UserBlockchainChunk`, подписи, PDA block `100`, crash-safety.
|
||||
2. `02_IMPLEMENTATION_MAP.md` — как спецификация разложена по Java/Rust/JS/SQL файлам текущего проекта.
|
||||
3. `03_DEPLOY_TEST_SERVER.md` — полный порядок установки на тестовый сервер, включая обязательный апгрейд `shine_users`, конфиг, ключи, сборку и запуск.
|
||||
4. `04_TEST_AND_OPERATIONS.md` — что проверять до полуночи, после полуночи и при сбоях.
|
||||
5. `05_PATCH_CONTENTS_AND_REMOVALS.md` — какие файлы содержит пакет и какие legacy test-free-avatar файлы нужно удалить при наложении ZIP поверх старого исходника.
|
||||
6. `07_ARCHIVE_IMPORT_AND_VIEWER.md` — whitelist доверенных publisher-ов, импорт archive chain, индекс последнего chunk, UI и `Blockchain-Viewer.html`.
|
||||
7. `archive-publisher.example.properties` — пример конфигурации publisher + importer.
|
||||
|
||||
## Коротко
|
||||
|
||||
- Архиватор **по умолчанию выключен**: `archive.publish.enabled=false`.
|
||||
- При включении создаёт новый snapshot **один раз в сутки в заданное локальное время**, по умолчанию `00:00`.
|
||||
- При первом успешном запуске, когда архивных курсоров ещё нет, в первый big block попадает **всё локально известное состояние всех blockchain, начиная с source block 0**.
|
||||
- Далее публикуется только дельта.
|
||||
- Один `blockchain_name` в одном big block представлен максимум одним `UserBlockchainChunk`; внутри него лежат все новые raw SHiNE records этой цепочки.
|
||||
- В конце chunk одна ссылка на предыдущий chunk этой же blockchain. Если blockchain в текущем big block отсутствует, её cursor/head не меняется.
|
||||
- Готовый файл сначала существует локально как `<login>.<00001>.<dd.MM.yy>.tmp.SHiNE-archive`. После успешной загрузки в Arweave он переименовывается в `<login>.<00001>.<dd.MM.yy>.<REAL_ARWEAVE_TX_ID>.SHiNE-archive` и остаётся локально.
|
||||
- После Arweave confirmations обычным `update_user_pda` обновляется PDA block type `100`: `archive_tx_id[32] + archive_hash[32]`.
|
||||
- Cursor commit выполняется только после Solana `finalized`.
|
||||
|
||||
## Важно перед тестом
|
||||
|
||||
Изменён формат/парсер `shine_users`. **Нельзя просто заменить серверный JAR и включить archive publisher, если целевая Solana-программа `shine_users` ещё не обновлена кодом из этого пакета.** Сначала обновить программу на нужном кластере, затем сервер.
|
||||
|
||||
|
||||
## Импорт архивов других серверов
|
||||
|
||||
Импорт по умолчанию также выключен. Настройка:
|
||||
|
||||
```properties
|
||||
archive.import.allowedPublishers=
|
||||
```
|
||||
|
||||
Пустой список означает: не доверять архивам ни одного внешнего сервера. Для разрешения перечислить логины через запятую. Подробности — `07_ARCHIVE_IMPORT_AND_VIEWER.md`.
|
||||
|
||||
Текущая схема БД: **v23**. `v22` добавила publisher, `v23` добавляет локальные `archive_imported/archive_last_imported_tx_id`, trusted importer и универсальный `archive_blockchain_location`.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Минимальный пример для archive-capable сервера.
|
||||
# Добавлять во внешний application.properties; существующие DB/Solana/server настройки не удалять.
|
||||
|
||||
archive.publish.enabled=true
|
||||
archive.publish.time=00:00
|
||||
archive.publish.zoneId=Europe/Warsaw
|
||||
archive.workDir=data/archive
|
||||
archive.maxFileBytes=4000000000
|
||||
|
||||
archive.arweave.gateway=https://arweave.net
|
||||
archive.arweave.walletJwkPath=/home/player/SHiNE/secrets/archive-arweave-wallet.json
|
||||
archive.arweave.minConfirmations=1
|
||||
archive.arweave.confirmPollSeconds=30
|
||||
archive.arweave.confirmTimeoutMinutes=180
|
||||
|
||||
# Отдельный archive Solana RPC НЕ задаётся.
|
||||
# Используется solana.users.sync.rpcUrl, иначе solana.rpcUrl.
|
||||
archive.solana.rootKeyPath=/home/player/SHiNE/secrets/server-root.key
|
||||
archive.solana.clientKeyPath=/home/player/SHiNE/secrets/server-client.key
|
||||
# Эти файлы могут содержать Base58 seed 32 bytes или Base58 Solana secret key 64 bytes.
|
||||
archive.solana.confirmPollSeconds=5
|
||||
archive.solana.confirmTimeoutMinutes=30
|
||||
archive.solana.commitment=finalized
|
||||
|
||||
|
||||
# =============================================================
|
||||
# Trusted archive import (independent from publisher)
|
||||
# Empty = do not import archives from any external server.
|
||||
# Comma-separated SHiNE server logins, case-insensitive.
|
||||
# =============================================================
|
||||
archive.import.allowedPublishers=
|
||||
archive.import.intervalMinutes=60
|
||||
archive.import.workDir=data/archive-import
|
||||
@@ -1,49 +1,116 @@
|
||||
# Общий формат добавляемого блока (Frame v0)
|
||||
# Общий формат пользовательского блока SHiNE — Frame v1 / ANS-104
|
||||
|
||||
Этот файл описывает **единый бинарный формат** блока, который клиент отправляет через `AddBlock` в поле `blockBytesB64`.
|
||||
Актуальный формат не поддерживает старый Frame v0. Новый пользовательский блок сразу создаётся как готовый подписанный ANS-104 DataItem.
|
||||
|
||||
## 1. Полная структура блока
|
||||
## 1. Два уровня формата
|
||||
|
||||
Блок состоит из двух частей:
|
||||
Полный объект, который клиент отправляет в `AddBlock`, хранится в PostgreSQL и затем архивируется в Arweave:
|
||||
|
||||
1. **PREIMAGE** (подписывается)
|
||||
2. **TAIL** (маркер подписи + подпись)
|
||||
```text
|
||||
ANS-104 DataItem
|
||||
signature_type = 2 (generic Ed25519)
|
||||
signature = 64 bytes
|
||||
owner = 32-byte blockchain public key
|
||||
target = absent
|
||||
anchor = absent
|
||||
tags
|
||||
data = SHiNE Frame v1
|
||||
```
|
||||
|
||||
### PREIMAGE
|
||||
Цепочка SHiNE **не зависит от Arweave**. Arweave DataItem ID хранится отдельно и используется для дедупликации/поиска, но не является `prevHash`.
|
||||
|
||||
- `frameCode (uint16)`
|
||||
- `prevHash32 (32 bytes)`
|
||||
- `blockSize (int32)` — размер PREIMAGE
|
||||
- `blockNumber (int32)`
|
||||
- `timestamp (int64)`
|
||||
- `type (uint16)`
|
||||
- `subType (uint16)`
|
||||
- `version (uint16)`
|
||||
- `bodyBytes (N)`
|
||||
## 2. SHiNE Frame v1
|
||||
|
||||
### TAIL
|
||||
Все целые поля Frame v1 — BigEndian.
|
||||
|
||||
- `sigMarker (uint16)`
|
||||
- `signature64 (64 bytes, Ed25519)`
|
||||
| Поле | Размер | Описание |
|
||||
|---|---:|---|
|
||||
| `frameCode` | 2 | `0x0001` |
|
||||
| `prevHash32` | 32 | SHA-256 полного Frame v1 предыдущего SHiNE-блока; для блока 0 — нули |
|
||||
| `blockSize` | 4 | точный размер Frame v1, включая header и body |
|
||||
| `blockNumber` | 4 | номер блока, начиная с 0 |
|
||||
| `timestamp` | 8 | Unix time seconds |
|
||||
| `type` | 2 | тип сообщения |
|
||||
| `subType` | 2 | подтип |
|
||||
| `version` | 2 | версия body |
|
||||
| `body` | N | данные конкретного типа |
|
||||
|
||||
## 2. Что проверяет сервер при AddBlock
|
||||
`FRAME_HEADER_SIZE = 56` bytes.
|
||||
|
||||
- `frameCode` должен быть `0x0000`.
|
||||
- `sigMarker` должен быть `0x0100`.
|
||||
- `blockNumber` должен идти строго по порядку (`last + 1`).
|
||||
- `prevHash32` должен совпасть с вершиной цепочки на сервере.
|
||||
- `body` должен пройти `check()` для конкретного типа.
|
||||
- подпись должна валидироваться публичным ключом блокчейна.
|
||||
```text
|
||||
blockHash32 = SHA256(FrameV1Bytes)
|
||||
next.prevHash32 = blockHash32
|
||||
```
|
||||
|
||||
## 3. Ограничения
|
||||
Подписи внутри Frame v1 нет. Единственная подпись пользователя — подпись окружающего ANS-104 DataItem.
|
||||
|
||||
- максимальный полный размер блока: до 4 MiB;
|
||||
- timestamp не должен сильно уходить в будущее;
|
||||
- `bodyBytes` парсится по `type/subType/version` из заголовка блока.
|
||||
## 3. ANS-104 DataItem
|
||||
|
||||
## 4. Почему это важно
|
||||
Для тестового контура используется generic Ed25519 signature type `2`: подпись 64 bytes, owner 32 bytes. Это отдельный generic Ed25519 signer type; Solana-specific signer в текущем Turbo/arbundles имеет другой type.
|
||||
|
||||
Одинаковый общий формат позволяет:
|
||||
- передавать разные виды записей через один RPC `AddBlock`;
|
||||
- валидировать блоки единообразно;
|
||||
- расширять типы `body`, не ломая каркас блока.
|
||||
Подписывается стандартный ANS-104 deep-hash:
|
||||
|
||||
```text
|
||||
[
|
||||
"dataitem",
|
||||
"1",
|
||||
"2",
|
||||
owner,
|
||||
target(empty),
|
||||
anchor(empty),
|
||||
rawAvroTags,
|
||||
FrameV1Bytes
|
||||
]
|
||||
```
|
||||
|
||||
`rawAvroTags` — ровно те Avro-serialized bytes тегов, которые лежат внутри DataItem.
|
||||
|
||||
`dataItemId32 = SHA256(signature64)`.
|
||||
|
||||
### Обязательный тестовый тег
|
||||
|
||||
Каждый блок:
|
||||
|
||||
```text
|
||||
App = test5590
|
||||
```
|
||||
|
||||
Это временное namespace-значение для разработки. Перед реальным запуском оно будет заменено отдельным изменением протокола/кода.
|
||||
|
||||
### Канальный тег
|
||||
|
||||
Если блок относится к конкретному каналу, он дополнительно содержит:
|
||||
|
||||
```text
|
||||
c = <canonical_channel_slug>
|
||||
```
|
||||
|
||||
Slug входит в подпись DataItem и не может быть изменён сервером после подписи.
|
||||
|
||||
## 4. Что хранится в PostgreSQL
|
||||
|
||||
`blocks.block_bytes` содержит **полный serialized ANS-104 DataItem**, а не только Frame.
|
||||
|
||||
Отдельно индексируются:
|
||||
|
||||
- `block_hash` — SHA-256(Frame v1);
|
||||
- `block_signature` — 64-byte Ed25519 signature из DataItem;
|
||||
- `data_item_id` — SHA-256(signature), UNIQUE;
|
||||
- `block_number`, `bch_name`, message fields;
|
||||
- состояние публикации в Arweave.
|
||||
|
||||
Локальные `.bch`, `.tmp_bch` и marker-файлы для пользовательских blockchain больше не используются.
|
||||
|
||||
## 5. Проверка AddBlock
|
||||
|
||||
Сервер обязан:
|
||||
|
||||
1. распарсить полный ANS-104 DataItem;
|
||||
2. проверить `App=test5590`;
|
||||
3. проверить `c`, если тип блока требует канал;
|
||||
4. проверить ANS-104 Ed25519 подпись;
|
||||
5. проверить, что `owner` равен текущему blockchain public key пользователя;
|
||||
6. распарсить Frame v1 и body;
|
||||
7. проверить `blockNumber == last + 1`;
|
||||
8. проверить `prevHash32 == lastBlockHash`;
|
||||
9. записать DataItem и новое состояние атомарно в PostgreSQL.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# ANS-104 / Arweave transport для пользовательских блоков SHiNE
|
||||
|
||||
## Цель
|
||||
|
||||
Каждый пользовательский блок уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер не переподписывает пользовательский контент: он проверяет его, хранит в PostgreSQL и объединяет готовые DataItems в стандартный ANS-104 bundle.
|
||||
|
||||
## Child DataItem tags
|
||||
|
||||
Обязательно для тестового контура:
|
||||
|
||||
```text
|
||||
App=test5590
|
||||
```
|
||||
|
||||
Дополнительно для блоков конкретного канала:
|
||||
|
||||
```text
|
||||
c=<canonical_channel_slug>
|
||||
```
|
||||
|
||||
Теги входят в ANS-104 подпись пользователя.
|
||||
|
||||
## Publisher
|
||||
|
||||
По умолчанию цикл — раз в 15 минут.
|
||||
|
||||
```text
|
||||
blocks.arweave_publish_pending=true
|
||||
↓
|
||||
готовые serialized DataItems
|
||||
↓
|
||||
ANS-104 binary bundle
|
||||
↓
|
||||
обычная Arweave L1 transaction
|
||||
```
|
||||
|
||||
Если pending-блоков нет, транзакция не создаётся.
|
||||
|
||||
Root transaction содержит стандартные bundle tags:
|
||||
|
||||
```text
|
||||
Bundle-Format=binary
|
||||
Bundle-Version=2.0.0
|
||||
Content-Type=application/octet-stream
|
||||
App=test5590-batch
|
||||
```
|
||||
|
||||
`App=test5590-batch` намеренно отличается от child `App=test5590`, чтобы discovery-запрос находил пользовательские блоки, а не root bundles.
|
||||
|
||||
После успешной L1-загрузки сервер ставит child-блокам:
|
||||
|
||||
- `arweave_publish_pending=false`;
|
||||
- `arweave_published_at_ms`;
|
||||
- `arweave_root_tx_id`.
|
||||
|
||||
## Importer
|
||||
|
||||
Каждый сервер может независимо искать:
|
||||
|
||||
```text
|
||||
App=test5590
|
||||
```
|
||||
|
||||
через GraphQL gateway с cursor pagination.
|
||||
|
||||
Для каждого нового DataItem:
|
||||
|
||||
1. взять `id` и `bundledIn.id`;
|
||||
2. получить root bundle;
|
||||
3. извлечь точные serialized bytes child DataItem по bundle index;
|
||||
4. проверить `dataItemId == SHA256(signature)`;
|
||||
5. проверить ANS-104 Ed25519 подпись;
|
||||
6. определить пользователя по `owner`;
|
||||
7. применить обычные проверки `AddBlock`;
|
||||
8. записать в PostgreSQL с `arweave_publish_pending=false`.
|
||||
|
||||
### Блоки могут прийти не по порядку
|
||||
|
||||
Discovery/import использует persistent queue `arweave_block_import_queue`. Если, например, block 102 увиден раньше block 101, block 102 остаётся `PENDING`; после появления 101 очередь повторно проигрывается.
|
||||
|
||||
## Дедупликация и несколько серверов
|
||||
|
||||
Один и тот же готовый DataItem имеет один `data_item_id = SHA256(signature)`. Если несколько серверов включили его в разные root bundles, локально это всё равно один логический блок: `blocks.data_item_id` уникален.
|
||||
|
||||
Импортированный из Arweave блок **не ставится обратно в publish queue**. Это предотвращает бесконечное переархивирование между серверами.
|
||||
|
||||
## Локальное хранение
|
||||
|
||||
Пользовательские blockchain-файлы на диске больше не используются. Полный serialized DataItem находится в `blocks.block_bytes` PostgreSQL.
|
||||
|
||||
## Настройки
|
||||
|
||||
См. `application.properties` и `CODEX_APPLY_ANS104_TEST5590_PATCH.md`.
|
||||
|
||||
## Что намеренно не входит в этот патч
|
||||
|
||||
Remote/homeserver signing path, связанный с внешним homeserver/ESP32 signer, не мигрируется этим патчем. Каталог `ESP32/` не изменяется. До отдельной миграции новый Frame v1/ANS-104 production path рассчитан на клиент, у которого локально доступен blockchain Ed25519 key.
|
||||
@@ -236,3 +236,16 @@
|
||||
- Добавлена поддержка командного префикса `/.` и команды `/.desc` для актуализации описания канала при чтении.
|
||||
- Зафиксированы команды `/.add` и `/.remove` для каналов `type=200` (зарезервировано под расширение участниками).
|
||||
- В `AGENTS.md` добавлено обязательное правило актуализации документации в `docs/Blockchain/`.
|
||||
|
||||
## 2026-09-20 — Frame v1 + ANS-104 DataItem / Arweave per-block transport
|
||||
|
||||
- Основание: пользователь явно подтвердил breaking change и запуск с чистой историей; patch archive, commit hash ещё отсутствует.
|
||||
- Удалена поддержка старого Frame v0 (`preimage + sigMarker + signature`).
|
||||
- Новый пользовательский block — SHiNE Frame v1 внутри стандартного ANS-104 DataItem, generic Ed25519 signature type 2.
|
||||
- Signing message синхронизирован с фактическим arbundles/Turbo DataItem: включает строку signature type и raw Avro tag bytes.
|
||||
- SHiNE chain hash остаётся независимым: `SHA256(FrameV1Bytes)`; Arweave DataItem ID хранится отдельно.
|
||||
- В тестовом контуре каждый DataItem подписывает tag `App=test5590`; channel blocks дополнительно `c=<canonical slug>`.
|
||||
- PostgreSQL стал единственным локальным хранилищем пользовательских block bytes; `.bch/.tmp_bch/marker` storage/recovery удалён.
|
||||
- Старый custom `SHINE-ARCHIVE` pipeline удалён. Добавлены standard ANS-104 bundle publisher через существующий Arweave L1 uploader и GraphQL importer.
|
||||
- Импортированные из Arweave blocks не публикуются повторно; дедупликация идёт по `data_item_id`.
|
||||
- ESP32 и связанный device code этим изменением не затронуты.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Инструкция Codex: применить ANS-104 test5590 patch
|
||||
|
||||
## Цель
|
||||
|
||||
Перевести пользовательский blockchain SHiNE на Frame v1 внутри готовых ANS-104 DataItems и убрать старый SHINE-ARCHIVE/файловое хранение цепочек.
|
||||
|
||||
## Жёсткое ограничение
|
||||
|
||||
**Не изменять ничего в `ESP32/`.** В этом patch нет ни одного файла `ESP32/**`.
|
||||
|
||||
Remote/homeserver signer, завязанный на устройство, намеренно не мигрирован. Не пытаться «заодно исправить» его в рамках этого patch.
|
||||
|
||||
## Применение
|
||||
|
||||
1. Распаковать patch поверх корня репозитория, сохраняя относительные пути.
|
||||
2. Удалить все пути из корневого `DELETE_FILES.txt`.
|
||||
3. Проверить, что `git diff -- ESP32` пуст.
|
||||
4. Использовать чистую/dev test DB. `migration_v24.sql` намеренно откажется мигрировать непустую blockchain DB, потому что backward compatibility со старым block format не требуется.
|
||||
|
||||
## Arweave config
|
||||
|
||||
Минимально для публикации:
|
||||
|
||||
```properties
|
||||
arweave.blocks.publish.enabled=true
|
||||
arweave.blocks.publish.intervalMinutes=15
|
||||
arweave.blocks.publish.gateway=https://arweave.net
|
||||
arweave.blocks.publish.walletJwkPath=/ABSOLUTE/SECRET/PATH/arweave-wallet.json
|
||||
```
|
||||
|
||||
JWK не коммитить.
|
||||
|
||||
Для discovery/import:
|
||||
|
||||
```properties
|
||||
arweave.blocks.sync.enabled=true
|
||||
arweave.blocks.sync.intervalMinutes=15
|
||||
arweave.blocks.sync.gateway=https://turbo-gateway.com
|
||||
arweave.blocks.sync.startBlockHeight=0
|
||||
```
|
||||
|
||||
На тестах желательно установить `startBlockHeight` на высоту начала `test5590`, чтобы не сканировать лишнюю историю.
|
||||
|
||||
## Test namespace
|
||||
|
||||
Child DataItem:
|
||||
|
||||
```text
|
||||
App=test5590
|
||||
```
|
||||
|
||||
Channel child:
|
||||
|
||||
```text
|
||||
App=test5590
|
||||
c=<canonical_channel_slug>
|
||||
```
|
||||
|
||||
Root bundle:
|
||||
|
||||
```text
|
||||
Bundle-Format=binary
|
||||
Bundle-Version=2.0.0
|
||||
App=test5590-batch
|
||||
```
|
||||
|
||||
Перед production-start test namespace должен быть заменён отдельным осознанным изменением.
|
||||
|
||||
## Проверки после применения
|
||||
|
||||
Из корня репозитория:
|
||||
|
||||
```bash
|
||||
node --check shine-UI/js/services/ans104-data-item.js
|
||||
node --check shine-UI/js/services/auth-service.js
|
||||
node --check shine-UI/js/app.js
|
||||
node --check shine-UI/js/pages/settings-view.js
|
||||
```
|
||||
|
||||
Java/Gradle:
|
||||
|
||||
```bash
|
||||
./gradlew testClasses
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
Затем локальный smoke test по штатной инструкции проекта, например `./gradlew startLocal`.
|
||||
|
||||
В среде, где готовился patch, Gradle wrapper не смог скачать Gradle 8.14 из-за отсутствия внешнего сетевого доступа к `services.gradle.org`. Поэтому полный Gradle compile/test обязательно прогнать после применения в обычной dev-среде.
|
||||
|
||||
## Smoke scenario
|
||||
|
||||
1. Создать/использовать тестового пользователя с локальным blockchain Ed25519 key.
|
||||
2. Добавить обычный block и убедиться, что `blocks.block_bytes` начинается с ANS-104 DataItem, а `data_item_id` заполнен.
|
||||
3. Создать channel и post; проверить `c=<canonical slug>`.
|
||||
4. Включить publisher, дождаться цикла или вызвать сервис тестом; проверить root Arweave tx.
|
||||
5. На второй чистой test DB включить importer и убедиться, что `App=test5590` blocks восстанавливаются в правильном порядке.
|
||||
6. Убедиться, что imported blocks имеют `arweave_publish_pending=false`.
|
||||
7. Проверить, что повторный discovery не создаёт дублей.
|
||||
|
||||
## Не делать в этом patch
|
||||
|
||||
- не добавлять backward compatibility Frame v0;
|
||||
- не возвращать `.bch` storage;
|
||||
- не возвращать SHINE-ARCHIVE;
|
||||
- не менять ESP32;
|
||||
- не мигрировать remote/homeserver signing без отдельного решения пользователя;
|
||||
- не заменять `prevHash` на Arweave DataItem ID.
|
||||
+34
-41
@@ -1,47 +1,40 @@
|
||||
# Документация блокчейна SHiNE (MVP)
|
||||
# SHiNE Blockchain
|
||||
|
||||
Этот каталог описывает только текущий рабочий формат протокола для MVP.
|
||||
Актуальная версия пользовательского blockchain использует **SHiNE Frame v1 внутри подписанного ANS-104 DataItem**. Старый Frame v0 и файловое хранение `.bch` не поддерживаются.
|
||||
|
||||
## Основные документы
|
||||
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
|
||||
Единый бинарный формат блока (Frame v0), подпись, базовые проверки.
|
||||
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
|
||||
Виды цепочек и правила line-полей.
|
||||
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
|
||||
Системные блоки (`msg_type=0`).
|
||||
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
|
||||
Текстовые блоки (`msg_type=1`).
|
||||
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
|
||||
Реакции (`msg_type=2`).
|
||||
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
|
||||
Социальные связи (`msg_type=3`).
|
||||
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
|
||||
Параметры пользователя (`msg_type=4`).
|
||||
8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md)
|
||||
Статусные действия пользователя (`msg_type=5`).
|
||||
9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md)
|
||||
Вложения в TEXT-сообщениях через `S:att v=1`, включая опциональные `preAr/preSha256` для видео и крупных изображений.
|
||||
10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md)
|
||||
Скрытый `TEXT_CHANNEL_META` для профиля канала.
|
||||
11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
||||
Типы каналов и формат `CreateChannelBody`.
|
||||
12. [02_Channel_Commands.md](./02_Channel_Commands.md)
|
||||
Команды в текстовых сообщениях каналов.
|
||||
13. [CHANGELOG.md](./CHANGELOG.md)
|
||||
Журнал изменений документации.
|
||||
|
||||
## Смежная документация
|
||||
- [../ИТХ/README.md](../ИТХ/README.md) — ежедневное закрытие блокчейна (ИТХ): краткий обзор.
|
||||
- [../ИТХ/Спецификация_ИТХ_v1.md](../ИТХ/Спецификация_ИТХ_v1.md) — точная спецификация чекпоинтов (Arweave/Solana/канал закрытий).
|
||||
- [sync-between-servers.md](./sync-between-servers.md) — живая межсерверная синхронизация блокчейна и доставка DM на единственный сервер получателя.
|
||||
- [`01_Common_Block_Format.md`](01_Common_Block_Format.md) — точный Frame v1, ANS-104, подпись, хэши и теги.
|
||||
- [`17_ANS104_Arweave_Transport.md`](17_ANS104_Arweave_Transport.md) — публикация блоков в Arweave и обратный импорт между серверами.
|
||||
- [`sync-between-servers.md`](sync-between-servers.md) — межсерверная синхронизация и full-resync без файловой копии blockchain.
|
||||
- [`CHANGELOG.md`](CHANGELOG.md) — история изменений протокола.
|
||||
|
||||
## Важные ограничения MVP
|
||||
- Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI.
|
||||
- Поддерживаемый рабочий сценарий UI на текущем этапе: `stories (type=0)` и `public (type=1)`.
|
||||
## Короткая схема
|
||||
|
||||
## Обязательное сопровождение
|
||||
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
|
||||
- Обычный `AddBlock` сейчас пишет через `<blockchainName>.tmp_bch`, `<blockchainName>.write_check` и `<blockchainName>.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery.
|
||||
- Для runtime-агрегатов статистики `user_stats_state` и `channel_stats_state` действует тот же принцип derived state: они обновляются вместе с `AddBlock` и полностью пересобираются при full resync.
|
||||
- Если в старых данных есть канал владельца, которого ещё нет в `solana_user_pda_current`, сервер не падает: `channel_stats_state` всё равно обновляется, а `user_stats_state` создаётся только после появления пользователя в Solana PDA.
|
||||
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
|
||||
```text
|
||||
User
|
||||
-> создаёт Frame v1
|
||||
-> tags: App=test5590, при канале c=<slug>
|
||||
-> Ed25519 подписывает ANS-104 deep-hash
|
||||
-> готовый DataItem
|
||||
-> AddBlock
|
||||
|
||||
Server
|
||||
-> verify DataItem + SHiNE chain
|
||||
-> PostgreSQL
|
||||
-> каждые ~15 минут ANS-104 bundle
|
||||
-> Arweave L1
|
||||
|
||||
Other servers
|
||||
-> GraphQL App=test5590
|
||||
-> скачивают/извлекают DataItem
|
||||
-> verify
|
||||
-> PostgreSQL без повторной публикации
|
||||
```
|
||||
|
||||
## Важные свойства
|
||||
|
||||
- SHiNE `prevHash` остаётся SHA-256 предыдущего Frame v1 и не зависит от Arweave.
|
||||
- `data_item_id` нужен для Arweave, поиска и дедупликации.
|
||||
- PostgreSQL — единственное локальное хранилище пользовательских блоков.
|
||||
- Тестовый namespace `App=test5590` специально отделён от будущего production namespace.
|
||||
|
||||
@@ -1,280 +1,42 @@
|
||||
# Синхронизация блокчейнов и доставка DM между серверами SHiNE
|
||||
# Синхронизация blockchain между SHiNE-серверами
|
||||
|
||||
Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE.
|
||||
## Локальная модель хранения
|
||||
|
||||
## 1. Зачем нужна синхронизация
|
||||
PostgreSQL является единственным локальным хранилищем пользовательских блоков. Файлы `<blockchain>.bch`, `.tmp_bch`, `.write_pending`, `.write_check`, `.resync_pending` не используются.
|
||||
|
||||
Пользователи SHiNE могут быть «приписаны» к разным серверам.
|
||||
Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y):
|
||||
`blocks.block_bytes` хранит полный подписанный ANS-104 DataItem. `blockchain_state` хранит текущую вершину цепочки.
|
||||
|
||||
1. Сервер X принимает сообщение;
|
||||
2. Сервер X должен переслать DM-блок серверу Y;
|
||||
3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B.
|
||||
## Обычный AddBlock
|
||||
|
||||
Аналогично, блоки пользовательского блокчейна (записи `AddBlock`) должны синхронизироваться,
|
||||
чтобы любой партнёрский сервер мог отдать полную историю пользователя.
|
||||
Все проверки и запись выполняются под lock конкретной chain. После проверки Frame v1, подписи и `prevHash` одна SQL-транзакция записывает block + derived state + новую вершину.
|
||||
|
||||
## 2. Список серверов синхронизации (`sync_servers`)
|
||||
Локально созданный пользовательский блок получает `arweave_publish_pending=true`.
|
||||
|
||||
Каждый сервер регистрирует в своей Solana PDA список `sync_servers` —
|
||||
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
|
||||
## Периодический peer-to-peer sync
|
||||
|
||||
`sync_servers` относится к серверному узлу и не является списком
|
||||
access-серверов обычного пользователя. У пользователя действует только первый
|
||||
`access_servers[0]`.
|
||||
Старый межсерверный P2P sync может получать `GetBlockchainBlock` и применять полученный полный DataItem через `AddBlock`. При divergence full-resync:
|
||||
|
||||
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
|
||||
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
|
||||
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
|
||||
1. берёт lock chain;
|
||||
2. очищает derived rows/blocks/state через `BlockchainResyncCleanupDAO`;
|
||||
3. пересоздаёт state из актуального пользовательского реестра;
|
||||
4. последовательно проигрывает удалённую цепочку с блока 0;
|
||||
5. никаких файловых swap/recovery операций нет.
|
||||
|
||||
## 3. Что синхронизируется
|
||||
## Arweave sync
|
||||
|
||||
### 3.1 Личные сообщения (DM)
|
||||
Дополнительно каждый сервер может независимо включить `ArweaveBlockSyncScheduler`.
|
||||
|
||||
- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt).
|
||||
- Сервер-отправитель: сохраняет пару и ставит асинхронную delivery-задачу.
|
||||
- Сервер-получатель: сохраняет входящий блок в `signed_messages`, затем доставляет его активным сессиям.
|
||||
- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`.
|
||||
- Между access-серверами одного пользователя DM не реплицируются.
|
||||
- Полная актуальная схема: `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`.
|
||||
Он ищет child DataItems по тестовому тегу `App=test5590`, проверяет их и импортирует через ту же бизнес-проверку AddBlock. Импортированные блоки не публикуются повторно.
|
||||
|
||||
### 3.2 Блоки пользовательского блокчейна
|
||||
Если блоки обнаружены не по порядку, persistent queue оставляет более поздние блоки pending до появления предыдущих.
|
||||
|
||||
- Все блоки `AddBlock` пользователей, зарегистрированных на сервере или синхронизирующихся через него.
|
||||
- Синхронизируются в обе стороны между всеми партнёрами из `sync_servers`.
|
||||
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
|
||||
- Дедупликация по глобальному номеру блока и хэшу.
|
||||
## Источник истины для цепочки
|
||||
|
||||
### 3.3 Пользовательские настройки
|
||||
Arweave не участвует в вычислении SHiNE chain hash:
|
||||
|
||||
Пользовательские настройки хранятся локально на единственном access-сервере и
|
||||
между серверами не синхронизируются.
|
||||
```text
|
||||
block_hash = SHA256(FrameV1Bytes)
|
||||
next.prevHash32 = block_hash
|
||||
```
|
||||
|
||||
## 4. Текущая реализованная схема
|
||||
|
||||
На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов.
|
||||
|
||||
### 4.1 Что уже сделано
|
||||
|
||||
1. При старте сервер читает свой `server.SHiNE.login`.
|
||||
2. По этому логину он загружает из Solana свою server PDA.
|
||||
3. Из неё вытаскивает список `sync_servers`.
|
||||
4. Для каждого логина партнёра сервер читает его PDA и сохраняет локально:
|
||||
- `login`
|
||||
- `server_address`
|
||||
5. После этого:
|
||||
- новые локальные `AddBlock` рассылаются партнёрам в фоне;
|
||||
- при старте запускается periodic sync;
|
||||
- periodic sync повторяется каждые `12` часов после старта.
|
||||
|
||||
### 4.2 Какие server-to-server API уже используются
|
||||
|
||||
- `ListBlockchainHeads` — список heads всех локальных цепочек партнёра;
|
||||
- `GetBlockchainBlock` — чтение одного конкретного блока партнёра;
|
||||
- `GetSyncUserProfile` — минимальный профиль пользователя для локального создания runtime-проекции пользователя и `blockchain_state` без обращения в Solana RPC.
|
||||
|
||||
### 4.3 Как сейчас работает periodic sync
|
||||
|
||||
Для каждого сервера из локальной таблицы `sync_servers`:
|
||||
|
||||
1. запрашивается `ListBlockchainHeads`;
|
||||
2. для каждой удалённой цепочки сравниваются:
|
||||
- `lastBlockNumber`
|
||||
- `lastBlockHash`
|
||||
- локальное состояние;
|
||||
3. если локальная цепочка слабее, сервер по одному блоку вызывает `GetBlockchainBlock`;
|
||||
4. каждый скачанный блок локально применяется через существующий `AddBlock`;
|
||||
5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальная runtime-проекция пользователя и `blockchain_state`.
|
||||
6. если во время replay обнаруживается рассинхрон или на одинаковой высоте удалённая цепочка сильнее, запускается полный resync:
|
||||
- цепочка помечается in-memory как `resync in progress`;
|
||||
- создаётся marker-file в `data/`;
|
||||
- в одной SQL-транзакции очищаются локальные данные цепочки и корректируются чужие счётчики;
|
||||
- удаляются `.bch` и `.tmp_bch`;
|
||||
- цепочка подтягивается заново с `0` через `GetBlockchainBlock`.
|
||||
- обычный `AddBlock` на эту цепочку в этот момент возвращает `chain_resync_in_progress`.
|
||||
|
||||
### 4.4 Как именно работает full resync
|
||||
|
||||
Full resync запускается только тогда, когда:
|
||||
|
||||
- локальная chain отстаёт и обычная докачка хвоста упирается в `bad_prev_hash` или `bad_block_number`;
|
||||
- либо высота цепочек одинаковая, но удалённая версия сильнее по правилу:
|
||||
- `lastBlockNumber`;
|
||||
- `fileSizeBytes`;
|
||||
- `lastBlockHash`.
|
||||
|
||||
Порядок действий:
|
||||
|
||||
1. Ставится in-memory guard на `blockchainName`.
|
||||
2. Создаётся marker-file `<blockchainName>.resync_pending`.
|
||||
3. Обычный `AddBlock` на эту chain временно получает `chain_resync_in_progress`.
|
||||
4. Вызывается атомарный SQL cleanup одной chain:
|
||||
- уменьшаются чужие `likes_count` и `replies_count`;
|
||||
- удаляются локальные derived-state записи этой chain;
|
||||
- удаляются `blocks` и `blockchain_state` этой chain.
|
||||
5. Удаляются файлы `<blockchainName>.bch` и `<blockchainName>.tmp_bch`.
|
||||
6. Локальная chain создаётся заново через `GetSyncUserProfile` или через Solana import, если `sync.importUserProfileFromPartner.enabled=false`.
|
||||
7. Chain replay-ится с `0` через `GetBlockchainBlock`.
|
||||
8. Если всё прошло успешно, marker-file удаляется.
|
||||
9. Если на любом шаге произошёл сбой, marker-file остаётся на диске, и сервер добивает эту chain при следующем старте.
|
||||
|
||||
Важно:
|
||||
|
||||
- full resync не делает умный rollback по одному блоку;
|
||||
- full resync не трогает DM-таблицы и current users слой;
|
||||
- висячие cross-chain ссылки считаются допустимым поведением системы.
|
||||
|
||||
### 4.5 Как работает обычный `AddBlock` и его recovery
|
||||
|
||||
Обычная запись блока теперь тоже идёт через временные артефакты:
|
||||
|
||||
1. собирается `<blockchainName>.tmp_bch` как полный кандидат на замену основного файла;
|
||||
2. пишется маленький sidecar `<blockchainName>.write_check` с `blockNumber` и `blockHash`;
|
||||
3. только после этого создаётся пустой marker `<blockchainName>.write_pending`;
|
||||
4. выполняется SQL-транзакция;
|
||||
5. после `commit` tmp атомарно ставится на место основного `.bch`;
|
||||
6. marker и sidecar удаляются.
|
||||
|
||||
На старте `BlockchainTmpRecoveryOnStartup` смотрит именно на эту пару:
|
||||
|
||||
- если `write_pending` есть, recovery проверяет sidecar и БД, а затем либо завершает swap, либо чистит временные файлы;
|
||||
- если `write_pending` нет, а `tmp_bch` или `write_check` остались, это мусор и он удаляется;
|
||||
- `resync_pending` сюда не относится, это отдельный recovery-поток.
|
||||
|
||||
### 4.6 Startup recovery по marker-file
|
||||
|
||||
При старте сервер идёт в таком порядке:
|
||||
|
||||
1. `BlockchainTmpRecoveryOnStartup` для `*.write_pending` и orphan `*.tmp_bch` / `*.write_check`;
|
||||
2. `BlockchainResyncRecoveryOnStartup` для `*.resync_pending`;
|
||||
3. только потом поднимается обычный сервер и запускается `PeriodicBlockchainSyncService`.
|
||||
|
||||
Если marker-file существует:
|
||||
|
||||
- сервер не должен начинать обычную работу поверх этой chain;
|
||||
- recovery снова выполняет cleanup и replay с нуля;
|
||||
- если recovery не завершился, marker остаётся, и сервер не переходит к обычному режиму для этой chain.
|
||||
|
||||
### 4.7 Зачем понадобился `GetSyncUserProfile`
|
||||
|
||||
Изначально подготовка локальной цепочки делалась через Solana:
|
||||
|
||||
- из `blockchainName` извлекался `login`;
|
||||
- сервер вызывал import пользователя из Solana PDA;
|
||||
- по данным PDA локально создавались runtime-проекция пользователя и `blockchain_state`.
|
||||
|
||||
На практике это упёрлось в ограничение внешнего Solana RPC: при чистом старте и массовой подтяжке чужих цепочек сервер мог получать `HTTP 429`.
|
||||
|
||||
Поэтому добавлен отдельный обходной режим:
|
||||
|
||||
- настройка `sync.importUserProfileFromPartner.enabled=true`
|
||||
- в этом режиме сервер **не ходит в Solana RPC** для создания локальной цепочки во время sync;
|
||||
- вместо этого он запрашивает у сервера-партнёра `GetSyncUserProfile` и создаёт локальную запись по данным партнёра.
|
||||
- если локальная runtime-проекция пользователя уже существует, sync восстанавливает только `blockchain_state` и не трогает user-layer.
|
||||
|
||||
Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint.
|
||||
|
||||
### 4.8 Что делает настройка `sync.importUserProfileFromPartner.enabled`
|
||||
|
||||
- `false` — стандартный режим, подготовка локального пользователя идёт через Solana PDA;
|
||||
- `true` — sync-режим обхода Solana, локальный пользователь создаётся по server-to-server `GetSyncUserProfile`.
|
||||
|
||||
Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.
|
||||
|
||||
## 5. Реализованный постоянный server-to-server транспорт
|
||||
|
||||
Этот раздел не меняет текущую семантику DM, settings и blockchain. Он описывает
|
||||
единый постоянный WSS-транспорт, через который выполняются уже существующие операции.
|
||||
|
||||
### 5.1 Межсерверное соединение
|
||||
|
||||
- Серверы устанавливают постоянное исходящее WebSocket-соединение друг с другом.
|
||||
- Адрес партнёра определяется по `server_address` из его Solana PDA.
|
||||
- После подключения отправляется `ServerHello` с `serverLogin`, версией протокола и capabilities.
|
||||
- На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена.
|
||||
- При разрыве выполняется переподключение с jitter/backoff до 60 секунд.
|
||||
- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд.
|
||||
- Один физический канал переиспользуют доставка DM и blockchain.
|
||||
|
||||
### 5.2 Доставка новых данных (push)
|
||||
|
||||
- При получении нового блока сервер может немедленно пушить его всем подключённым партнёрам.
|
||||
- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
|
||||
- DM использует отдельное расписание, описанное в `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`.
|
||||
|
||||
### 5.3 Начальная синхронизация (backfill)
|
||||
|
||||
- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов.
|
||||
- Сервер с более полной историей досылает недостающее партнёру.
|
||||
- DM-history backfill между access-серверами отсутствует.
|
||||
|
||||
### 5.4 Разрешение конфликтов
|
||||
|
||||
- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока.
|
||||
Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `docs/Blockchain/README.md`).
|
||||
- DM: конфликтов нет, `message_key` уникален.
|
||||
|
||||
## 6. Маршрутизация DM между серверами
|
||||
|
||||
При отправке DM от пользователя A к пользователю B:
|
||||
|
||||
1. Клиент A отправляет пару блоков на свой сервер X.
|
||||
2. Сервер X валидирует и локально сохраняет пару.
|
||||
3. До ответа клиенту X отправляет входящую копию на единственный
|
||||
`access_servers[0]` пользователя B через `ReceiveIncomingMessage`.
|
||||
4. Успешное сохранение на сервере B означает терминальный `delivered`.
|
||||
5. При неудаче выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час.
|
||||
6. После неудачной попытки через час ставится терминальный `failed`.
|
||||
|
||||
Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке.
|
||||
|
||||
## 7. Безопасность
|
||||
|
||||
- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое.
|
||||
- Серверы не расшифровывают DM-контент; E2EE уже выполняется клиентами.
|
||||
- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере.
|
||||
- Межсерверная авторизация DM-операций пока отложена; `sourceServerLogin` временно считается доверенным.
|
||||
|
||||
## 8. Статус реализации
|
||||
|
||||
| Компонент | Статус |
|
||||
|-----------|--------|
|
||||
| Регистрация серверной PDA в Solana | ✅ Реализовано |
|
||||
| Чтение `sync_servers` из PDA | ✅ Реализовано |
|
||||
| Локальная таблица `sync_servers` | ✅ Реализовано |
|
||||
| Публичный `ListBlockchainHeads` | ✅ Реализовано |
|
||||
| Публичный `GetBlockchainBlock` | ✅ Реализовано |
|
||||
| Публичный `GetSyncUserProfile` | ✅ Реализовано |
|
||||
| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано |
|
||||
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
|
||||
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
|
||||
| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` |
|
||||
| Асинхронная доставка DM на единственный access-сервер получателя | ✅ Реализовано |
|
||||
| Retry DM до 1 часа + UI-state | ✅ Реализовано |
|
||||
| Репликация DM и настроек между access-серверами | Не используется |
|
||||
| Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул |
|
||||
| Periodic backfill отсутствующего хвоста | ✅ Реализовано |
|
||||
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
|
||||
| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано |
|
||||
| Маршрутизация DM только через `access_servers[0]` | ✅ Реализовано |
|
||||
| Криптографическая server-to-server авторизация DM | Нужна реализация |
|
||||
|
||||
Текущая версия сервера использует постоянный WSS-пул для существующих
|
||||
server-to-server JSON-операций. Отдельной будущей задачей остаётся
|
||||
криптографическая авторизация server-to-server вызовов.
|
||||
|
||||
Следующие отдельные шаги после текущего этапа:
|
||||
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
|
||||
|
||||
### 8.1 Практическая проверка на тестовом сервере
|
||||
|
||||
Проверка на `server2.shineup.me` показала, что текущая схема действительно поднимает цепочку при старте:
|
||||
|
||||
- после рестарта сервер сначала проходит `BlockchainTmpRecovery`;
|
||||
- затем обрабатывает `BlockchainResyncRecovery`;
|
||||
- после этого сам догружает цепочку `aidartest-001` с `shineup.me`;
|
||||
- итоговое состояние на тестовом сервере:
|
||||
- `blockchain_state.last_block_number = 13`
|
||||
- `blocks` по `aidartest-001` = `14` записей
|
||||
|
||||
Это подтверждает, что startup sync и full-resync flow работают в живом сценарии, а не только в коде.
|
||||
Поэтому цепочка остаётся проверяемой и переносимой независимо от конкретного storage backend.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -94,7 +94,7 @@ UserPdaRecordV1
|
||||
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
|
||||
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. |
|
||||
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
|
||||
| `100` | `ArchiveHeadBlock` | Текущая голова серверного SHINE-ARCHIVE: Arweave TX ID + SHA-256 архива. |
|
||||
| `100` | `ArchiveHeadBlock` | Legacy/reserved. Новый per-block ANS-104 transport не использует это поле для пользовательской истории. |
|
||||
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
|
||||
|
||||
Правила:
|
||||
@@ -374,7 +374,7 @@ ArchiveHeadBlock
|
||||
|
||||
Семантика:
|
||||
|
||||
- `archive_tx_id` — raw 32-byte Arweave transaction id последнего опубликованного большого `SHINE-ARCHIVE`; текстовая Base64URL-форма получается вне PDA;
|
||||
- `archive_tx_id` — legacy/reserved поле старой archive-head схемы; новый per-block ANS-104 transport его не обновляет;
|
||||
- `archive_hash` — SHA-256 большого archive block по правилам `docs/Archive/01_PROTOCOL_v1.0.md`;
|
||||
- отсутствие block `100` означает, что аккаунт ещё не объявлял archive head;
|
||||
- обычный legacy `update_user_pda`, в instruction которого archive extension отсутствует, **обязан сохранить существующий ArchiveHeadBlock без изменений**;
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
- upgrade-authority программы после проверки передается DAO;
|
||||
- пользовательские операции `create_user_pda` и `update_user_pda` остаются доступными обычным пользователям при корректных подписях и оплате.
|
||||
|
||||
## ArchiveHeadBlock и серверный SHINE-ARCHIVE
|
||||
## ArchiveHeadBlock (legacy/reserved)
|
||||
|
||||
Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`):
|
||||
|
||||
@@ -145,7 +145,7 @@ archive_tx_id [32]
|
||||
archive_hash [32]
|
||||
```
|
||||
|
||||
Он хранит текущую голову архива конкретного SHiNE-аккаунта: raw Arweave TX ID и SHA-256 соответствующего большого `SHINE-ARCHIVE`. Подробный бинарный формат и серверный workflow находятся в `docs/Archive/01_PROTOCOL_v1.0.md`.
|
||||
Поле `ArchiveHeadBlock` осталось в Solana/PDA как legacy/reserved для совместимости формата PDA. Новый transport пользовательских блоков не использует server-level SHINE-ARCHIVE или archive head: каждый пользовательский block публикуется как ANS-104 DataItem внутри стандартных bundles.
|
||||
|
||||
Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим:
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
shine-server-blockchain — это библиотека, которая задаёт формат блока, правила парсинга/валидации тела, крипто-проверку (hash+Ed25519) и безопасную работу с файлами блокчейна (data/<name>.bch через временный .tmp_bch).
|
||||
shine-server-blockchain задаёт Frame v1, ANS-104 DataItem, правила парсинга/валидации body и криптографическую проверку. Пользовательские chain-файлы на диске больше не используются; полные DataItems хранятся в PostgreSQL.
|
||||
|
||||
Как устроена структура и логика работы
|
||||
|
||||
@@ -33,7 +33,6 @@ BchCryptoVerifier отвечает за “как получить хэш и к
|
||||
4) Утилиты вокруг имени и файлов
|
||||
|
||||
BlockchainNameUtil — извлекает login из blockchainName (отрезает 3 символа суффикса).
|
||||
FileStoreUtil — безопасное файловое хранилище:
|
||||
|
||||
|
||||
5) Объяснение структуры работы
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-proc:none
|
||||
-d
|
||||
/tmp/javac-check
|
||||
-sourcepath
|
||||
SHiNE-server/shine-server-geo/src/main/java:SHiNE-server/shine-server-log/src/main/java:SHiNE-server/shine-server-solana-users-sync/src/main/java:SHiNE-server/shine-server-db/src/main/java:SHiNE-server/src/main/java:SHiNE-server/shine-server-archive/src/main/java:SHiNE-server/shine-server-blockchain/src/main/java:SHiNE-server/shine-server-crypto/src/main/java:SHiNE-server/shine-server-net-protocol/src/main/java:SHiNE-server/shine-server-config/src/main/java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/src/main/java/server/ws/WsServer.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java
|
||||
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java
|
||||
File diff suppressed because one or more lines are too long
@@ -73,7 +73,6 @@ import * as profileEditView from './pages/profile-edit-view.js';
|
||||
import * as profilesView from './pages/profiles-view.js';
|
||||
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||
import * as settingsView from './pages/settings-view.js';
|
||||
import * as blockchainArchiveView from './pages/blockchain-archive-view.js';
|
||||
import * as accessServersView from './pages/access-servers-view.js';
|
||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||
import * as advancedSettingsView from './pages/advanced-settings-view.js';
|
||||
@@ -142,7 +141,6 @@ const routes = {
|
||||
'profiles-view': profilesView,
|
||||
'wallet-view': walletView,
|
||||
'settings-view': settingsView,
|
||||
'blockchain-archive-view': blockchainArchiveView,
|
||||
'access-servers-view': accessServersView,
|
||||
'developer-settings-view': developerSettingsView,
|
||||
'advanced-settings-view': advancedSettingsView,
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'blockchain-archive-view', title: 'Архив блокчейна' };
|
||||
|
||||
function text(value) {
|
||||
return String(value == null ? '' : value).trim();
|
||||
}
|
||||
|
||||
function buildViewerUrl(location, blockchainName, channelName, messageNumber) {
|
||||
const url = new URL('/Blockchain-Viewer.html', window.location.origin);
|
||||
url.searchParams.set('tx', text(location.arweaveTxId));
|
||||
url.searchParams.set('offset', String(location.chunkOffset));
|
||||
url.searchParams.set('size', String(location.chunkSize));
|
||||
url.searchParams.set('blockchain', blockchainName);
|
||||
const channel = text(channelName);
|
||||
if (channel) url.searchParams.set('channel', channel);
|
||||
const message = text(messageNumber);
|
||||
if (message) url.searchParams.set('message', message);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function copyText(value) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
const area = document.createElement('textarea');
|
||||
area.value = value;
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
document.execCommand('copy');
|
||||
area.remove();
|
||||
}
|
||||
|
||||
export function render({ navigate, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Архив блокчейна',
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<div class="stack" style="gap:.35rem;">
|
||||
<strong>Последний архивный блок</strong>
|
||||
<div id="archive-status" class="meta-muted">Загружаю...</div>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Блокчейн</span>
|
||||
<input id="archive-blockchain" type="text" readonly>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Канал (необязательно)</span>
|
||||
<input id="archive-channel" type="text" placeholder="Название канала">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Номер сообщения / блока (необязательно)</span>
|
||||
<input id="archive-message" type="number" min="0" step="1" placeholder="Например, 125">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Ссылка</span>
|
||||
<textarea id="archive-link" rows="5" readonly style="resize:vertical;"></textarea>
|
||||
</label>
|
||||
<div style="display:flex; gap:.6rem; flex-wrap:wrap;">
|
||||
<button id="archive-copy" class="shine-btn" type="button" disabled>Копировать ссылку</button>
|
||||
<button id="archive-open" class="shine-btn" type="button" disabled>Открыть Viewer</button>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Технические данные</summary>
|
||||
<pre id="archive-tech" style="white-space:pre-wrap; word-break:break-all;"></pre>
|
||||
</details>
|
||||
`;
|
||||
screen.appendChild(card);
|
||||
|
||||
const status = card.querySelector('#archive-status');
|
||||
const blockchainInput = card.querySelector('#archive-blockchain');
|
||||
const channelInput = card.querySelector('#archive-channel');
|
||||
const messageInput = card.querySelector('#archive-message');
|
||||
const linkInput = card.querySelector('#archive-link');
|
||||
const copyButton = card.querySelector('#archive-copy');
|
||||
const openButton = card.querySelector('#archive-open');
|
||||
const tech = card.querySelector('#archive-tech');
|
||||
|
||||
let location = null;
|
||||
let blockchainName = '';
|
||||
|
||||
function refreshLink() {
|
||||
if (!location || !blockchainName) {
|
||||
linkInput.value = '';
|
||||
copyButton.disabled = true;
|
||||
openButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
linkInput.value = buildViewerUrl(location, blockchainName, channelInput.value, messageInput.value);
|
||||
copyButton.disabled = false;
|
||||
openButton.disabled = false;
|
||||
}
|
||||
|
||||
channelInput.addEventListener('input', refreshLink);
|
||||
messageInput.addEventListener('input', refreshLink);
|
||||
copyButton.addEventListener('click', async () => {
|
||||
if (!linkInput.value) return;
|
||||
await copyText(linkInput.value);
|
||||
status.textContent = 'Ссылка скопирована.';
|
||||
});
|
||||
openButton.addEventListener('click', () => {
|
||||
if (linkInput.value) window.open(linkInput.value, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const login = text(state.session.login);
|
||||
if (!login) throw new Error('Нет активного пользователя');
|
||||
const user = await authService.getUser(login);
|
||||
blockchainName = text(user.blockchainName);
|
||||
if (!blockchainName) throw new Error('У пользователя не найден blockchainName');
|
||||
blockchainInput.value = blockchainName;
|
||||
location = await authService.getArchiveBlockchainLocation(blockchainName);
|
||||
if (!text(location.arweaveTxId) || !Number.isFinite(Number(location.chunkOffset)) || !Number.isFinite(Number(location.chunkSize))) {
|
||||
throw new Error('Сервер вернул неполную архивную ссылку');
|
||||
}
|
||||
status.textContent = `Известна архивная история до блока ${location.sourceLastBlockNumber ?? '—'}.`;
|
||||
tech.textContent = [
|
||||
`publisher: ${text(location.publisherLogin) || '—'}`,
|
||||
`archive big block: ${location.bigBlockNumber ?? '—'}`,
|
||||
`Arweave TX: ${text(location.arweaveTxId)}`,
|
||||
`archive hash: ${text(location.archiveHash) || '—'}`,
|
||||
`chunk offset: ${location.chunkOffset}`,
|
||||
`chunk size: ${location.chunkSize}`,
|
||||
`source last block: ${location.sourceLastBlockNumber ?? '—'}`,
|
||||
].join('\n');
|
||||
refreshLink();
|
||||
} catch (error) {
|
||||
status.textContent = `Архивная ссылка пока недоступна: ${error?.message || error}`;
|
||||
tech.textContent = '';
|
||||
refreshLink();
|
||||
}
|
||||
})();
|
||||
|
||||
return screen;
|
||||
}
|
||||
@@ -71,12 +71,6 @@ export function render({navigate, chrome}) {
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-blockchain-archive">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Архив блокчейна</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Ссылка на архивную историю вашего публичного блокчейна</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-arweave-uploads">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Загрузить файлы в блокчейн</strong>
|
||||
@@ -91,7 +85,6 @@ export function render({navigate, chrome}) {
|
||||
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
|
||||
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
|
||||
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||
card.querySelector('#settings-blockchain-archive').addEventListener('click', () => navigate('blockchain-archive-view'));
|
||||
card.querySelector('#settings-arweave-uploads').addEventListener('click', () => navigate('arweave-uploads-view'));
|
||||
card.querySelector('#settings-language').addEventListener('click', () => {
|
||||
sessionStorage.setItem('shine-language-return-page', 'settings-view');
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
const TE = new TextEncoder();
|
||||
export const ANS104_ED25519_SIGNATURE_TYPE = 2;
|
||||
|
||||
function concatBytes(...chunks) {
|
||||
const len = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const out = new Uint8Array(len);
|
||||
let p = 0;
|
||||
for (const c of chunks) { out.set(c, p); p += c.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
function uint16LE(value) {
|
||||
const out = new Uint8Array(2);
|
||||
new DataView(out.buffer).setUint16(0, Number(value), true);
|
||||
return out;
|
||||
}
|
||||
|
||||
function uint64LE(value) {
|
||||
let v = BigInt(value);
|
||||
if (v < 0n) throw new Error('uint64LE: negative value');
|
||||
const out = new Uint8Array(8);
|
||||
for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; }
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeAvroLong(value) {
|
||||
let n = BigInt(value);
|
||||
// Avro zig-zag signed long.
|
||||
let u = n >= 0n ? (n << 1n) : ((-n << 1n) - 1n);
|
||||
const bytes = [];
|
||||
do {
|
||||
let b = Number(u & 0x7fn);
|
||||
u >>= 7n;
|
||||
if (u !== 0n) b |= 0x80;
|
||||
bytes.push(b);
|
||||
} while (u !== 0n);
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
function encodeAvroString(value) {
|
||||
const bytes = TE.encode(String(value));
|
||||
return concatBytes(encodeAvroLong(bytes.length), bytes);
|
||||
}
|
||||
|
||||
/** Serialize ANS-104 tags as the Avro array used by arbundles/Turbo. */
|
||||
export function serializeAns104Tags(tags = []) {
|
||||
const normalized = tags.map(({ name, value }) => ({
|
||||
name: String(name ?? ''),
|
||||
value: String(value ?? ''),
|
||||
}));
|
||||
if (normalized.length > 128) throw new Error('ANS-104 supports at most 128 tags');
|
||||
for (const tag of normalized) {
|
||||
if (!tag.name || !tag.value) throw new Error('ANS-104 tag name/value must be non-empty');
|
||||
if (TE.encode(tag.name).length > 1024) throw new Error('ANS-104 tag name exceeds 1024 bytes');
|
||||
if (TE.encode(tag.value).length > 3072) throw new Error('ANS-104 tag value exceeds 3072 bytes');
|
||||
}
|
||||
if (normalized.length === 0) return Uint8Array.of(0);
|
||||
return concatBytes(
|
||||
encodeAvroLong(normalized.length),
|
||||
...normalized.flatMap((tag) => [encodeAvroString(tag.name), encodeAvroString(tag.value)]),
|
||||
Uint8Array.of(0),
|
||||
);
|
||||
}
|
||||
|
||||
async function digest(name, bytes) {
|
||||
return new Uint8Array(await crypto.subtle.digest(name, bytes));
|
||||
}
|
||||
|
||||
async function deepHashBlob(bytes) {
|
||||
const tag = TE.encode(`blob${bytes.length}`);
|
||||
return digest('SHA-384', concatBytes(await digest('SHA-384', tag), await digest('SHA-384', bytes)));
|
||||
}
|
||||
|
||||
async function deepHash(item) {
|
||||
if (item instanceof Uint8Array) return deepHashBlob(item);
|
||||
if (Array.isArray(item)) {
|
||||
let acc = await digest('SHA-384', TE.encode(`list${item.length}`));
|
||||
for (const child of item) {
|
||||
acc = await digest('SHA-384', concatBytes(acc, await deepHash(child)));
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
throw new Error('Unsupported ANS-104 deepHash item');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exact signing message used by current arbundles/Turbo DataItems.
|
||||
* SHiNE intentionally uses no target and no anchor.
|
||||
*/
|
||||
export async function ans104SigningMessage({ owner32, tags = [], data }) {
|
||||
if (!(owner32 instanceof Uint8Array) || owner32.length !== 32) throw new Error('ANS-104 owner must be 32 bytes');
|
||||
if (!(data instanceof Uint8Array)) throw new Error('ANS-104 data must be Uint8Array');
|
||||
const rawTags = serializeAns104Tags(tags);
|
||||
return deepHash([
|
||||
TE.encode('dataitem'),
|
||||
TE.encode('1'),
|
||||
TE.encode(String(ANS104_ED25519_SIGNATURE_TYPE)),
|
||||
owner32,
|
||||
new Uint8Array(0),
|
||||
new Uint8Array(0),
|
||||
rawTags,
|
||||
data,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Create one complete serialized ANS-104 DataItem signed with Ed25519. */
|
||||
export async function createAns104DataItem({ owner32, privateKey, data, tags = [] }) {
|
||||
if (!(data instanceof Uint8Array)) throw new Error('ANS-104 data must be Uint8Array');
|
||||
const rawTags = serializeAns104Tags(tags);
|
||||
const signingMessage = await ans104SigningMessage({ owner32, tags, data });
|
||||
const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, signingMessage));
|
||||
if (signature.length !== 64) throw new Error(`Unexpected Ed25519 signature length: ${signature.length}`);
|
||||
|
||||
return concatBytes(
|
||||
uint16LE(ANS104_ED25519_SIGNATURE_TYPE),
|
||||
signature,
|
||||
owner32,
|
||||
Uint8Array.of(0), // target absent
|
||||
Uint8Array.of(0), // anchor absent
|
||||
uint64LE(tags.length),
|
||||
uint64LE(rawTags.length),
|
||||
rawTags,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
saveEncryptedUserSecrets,
|
||||
saveSessionMaterial,
|
||||
} from './key-vault.js';
|
||||
import { createAns104DataItem } from './ans104-data-item.js';
|
||||
import { defaultServerWs } from '../deploy-config.js';
|
||||
|
||||
const BCH_SUFFIX = '001';
|
||||
@@ -973,7 +974,7 @@ function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType
|
||||
const blockSize = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + body.length;
|
||||
|
||||
return concatBytes(
|
||||
int16Bytes(0),
|
||||
int16Bytes(1),
|
||||
prevHashBytes,
|
||||
int32Bytes(blockSize),
|
||||
int32Bytes(blockNumber),
|
||||
@@ -1077,14 +1078,6 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getArchiveBlockchainLocation(blockchainName) {
|
||||
const cleanBlockchainName = String(blockchainName || '').trim();
|
||||
if (!cleanBlockchainName) throw new Error('Не указано имя блокчейна');
|
||||
const response = await this.ws.request('GetArchiveBlockchainLocation', { blockchainName: cleanBlockchainName });
|
||||
if (response.status !== 200) throw opError('GetArchiveBlockchainLocation', response);
|
||||
return response.payload || response || {};
|
||||
}
|
||||
|
||||
async resolveLoginForAuth(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Введите логин');
|
||||
@@ -1645,7 +1638,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async submitPreparedAddBlock({ login, storagePwd, blockchainName, blockNumber, prevBlockHash, preimage }) {
|
||||
async submitPreparedAddBlock({ login, storagePwd, blockchainName, blockNumber, prevBlockHash, preimage, ansTags }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanBlockchainName = String(blockchainName || '').trim();
|
||||
const cleanPrevBlockHash = normalizeHex32(prevBlockHash, ZERO_HASH_HEX);
|
||||
@@ -1658,17 +1651,22 @@ export class AuthService {
|
||||
const blockchainPrivatePkcs8 = String(savedKeys?.blockchainKey || '').trim();
|
||||
if (blockchainPrivatePkcs8) {
|
||||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||||
const hash32 = await sha256Bytes(preimage);
|
||||
const signatureBytes = await signBytes(privateKey, hash32);
|
||||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||||
const owner32 = base64ToBytes(await publicKeyB64FromPkcs8Ed25519(blockchainPrivatePkcs8));
|
||||
const dataItemBytes = await createAns104DataItem({
|
||||
owner32,
|
||||
privateKey,
|
||||
data: preimage,
|
||||
tags: Array.isArray(ansTags) && ansTags.length ? ansTags : [{ name: 'App', value: 'test5590' }],
|
||||
});
|
||||
return this.ws.request('AddBlock', {
|
||||
blockchainName: cleanBlockchainName,
|
||||
blockNumber: Number(blockNumber),
|
||||
prevBlockHash: cleanPrevBlockHash,
|
||||
blockBytesB64: bytesToBase64(fullBlock),
|
||||
blockBytesB64: bytesToBase64(dataItemBytes),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim();
|
||||
if (!remoteSessionId) {
|
||||
throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock');
|
||||
@@ -1782,7 +1780,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async runAddBlockWithRetry({ login, storagePwd, resolveFreshState, buildPreimage }) {
|
||||
async runAddBlockWithRetry({ login, storagePwd, resolveFreshState, buildPreimage, ansTags }) {
|
||||
let freshState = await resolveFreshState();
|
||||
let blockchainName = String(freshState?.blockchainName || '').trim();
|
||||
if (!blockchainName) throw new Error('runAddBlockWithRetry: blockchainName is empty');
|
||||
@@ -1798,6 +1796,7 @@ export class AuthService {
|
||||
blockNumber,
|
||||
prevBlockHash,
|
||||
preimage,
|
||||
ansTags,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1824,7 +1823,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes, channelSlug = '' }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
||||
if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');
|
||||
@@ -1844,10 +1843,15 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
const ansTags = [{ name: 'App', value: 'test5590' }];
|
||||
const cleanChannelSlug = String(channelSlug || '').trim();
|
||||
if (cleanChannelSlug) ansTags.push({ name: 'c', value: cleanChannelSlug });
|
||||
|
||||
const { response, blockchainName } = await this.runAddBlockWithRetry({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
|
||||
ansTags,
|
||||
buildPreimage: async ({ blockNumber, prevBlockHash }) => buildBlockPreimage({
|
||||
prevBlockHashHex: prevBlockHash,
|
||||
blockNumber,
|
||||
@@ -2039,12 +2043,14 @@ export class AuthService {
|
||||
if (!owner || !Number.isFinite(root) || root < 0) throw new Error('Invalid channel selector');
|
||||
if (owner !== blockchainName) throw new Error('Repost is allowed only to your own channels');
|
||||
|
||||
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || ''));
|
||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) {
|
||||
if (rootHashHex === ZERO64 || !channelSlug) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === root);
|
||||
if (!rootChannel) throw new Error('Channel root not found');
|
||||
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
|
||||
}
|
||||
|
||||
let prevLineNumber = root;
|
||||
@@ -2089,6 +2095,7 @@ export class AuthService {
|
||||
msgSubType: MSG_SUBTYPE_TEXT_REPOST,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
channelSlug,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2115,11 +2122,15 @@ export class AuthService {
|
||||
throw new Error('Invalid channel selector for edit');
|
||||
}
|
||||
|
||||
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || ''));
|
||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) {
|
||||
if (rootHashHex === ZERO64 || !channelSlug) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, ownerBlockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||||
if (rootChannel) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
if (rootChannel) {
|
||||
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
|
||||
}
|
||||
}
|
||||
|
||||
let prevLineNumber = lineCode;
|
||||
@@ -2165,6 +2176,7 @@ export class AuthService {
|
||||
msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
channelSlug,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2375,6 +2387,7 @@ export class AuthService {
|
||||
msgType: MSG_TYPE_TECH,
|
||||
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||||
msgVersion: CREATE_CHANNEL_BODY_VERSION,
|
||||
channelSlug,
|
||||
bodyBytes: makeCreateChannelBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber,
|
||||
@@ -2413,13 +2426,16 @@ export class AuthService {
|
||||
throw new Error('Posting is allowed only to your own channels');
|
||||
}
|
||||
|
||||
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || ''));
|
||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) {
|
||||
if (rootHashHex === ZERO64 || !channelSlug) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(login, blockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||||
if (!rootChannel) throw new Error('Channel root not found');
|
||||
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
|
||||
}
|
||||
if (!channelSlug) throw new Error('Cannot resolve canonical channel slug');
|
||||
|
||||
let prevLineNumber = lineCode;
|
||||
let prevLineHashHex = rootHashHex;
|
||||
@@ -2462,6 +2478,7 @@ export class AuthService {
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelSlug,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2506,6 +2523,7 @@ export class AuthService {
|
||||
msgSubType: cleanSubType,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
channelSlug: tail.channelSlug,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -2547,6 +2565,7 @@ export class AuthService {
|
||||
msgSubType: MSG_SUBTYPE_TEXT_CHANNEL_META,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
channelSlug: tail.channelSlug,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user