diff --git a/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java b/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java index f624691..ecec648 100644 --- a/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java +++ b/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java @@ -79,6 +79,15 @@ public final class BchBlockEntry { // ✅ Сразу парсим BodyRecord (и если неизвестный type/version — тут же упадём) this.body = BodyRecordParser.parse(this.bodyBytes); + // ✅ УРОВЕНЬ B: проверка ожидаемой линии по типу body + short expectedLine = this.body.expectedLineIndex(); + if (this.lineIndex != expectedLine) { + throw new IllegalArgumentException( + "Body is in wrong lineIndex: expected=" + expectedLine + " actual=" + this.lineIndex + + " (type=" + this.body.type() + " ver=" + this.body.version() + ")" + ); + } + this.signature64 = new byte[SIGNATURE_LEN]; bb.get(this.signature64); @@ -118,6 +127,15 @@ public final class BchBlockEntry { // ✅ И при сборке — тоже сразу парсим body (чтобы объект был цельным) this.body = BodyRecordParser.parse(this.bodyBytes); + // ✅ УРОВЕНЬ B: проверка ожидаемой линии по типу body + short expectedLine = this.body.expectedLineIndex(); + if (this.lineIndex != expectedLine) { + throw new IllegalArgumentException( + "Body is in wrong lineIndex: expected=" + expectedLine + " actual=" + this.lineIndex + + " (type=" + this.body.type() + " ver=" + this.body.version() + ")" + ); + } + this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN); this.hash32 = Arrays.copyOf(hash32, HASH_LEN); @@ -140,14 +158,12 @@ public final class BchBlockEntry { } public byte[] getRawBytes() { - int rawLen = recordSize; // теперь это ровно RAW, без signature+hash + int rawLen = recordSize; // ровно RAW, без signature+hash byte[] raw = new byte[rawLen]; System.arraycopy(fullBytes, 0, raw, 0, rawLen); return raw; } - /* ===================================================================== */ - public byte[] getSignature64() { return Arrays.copyOf(signature64, SIGNATURE_LEN); } diff --git a/shine-server-blockchain/src/main/java/blockchain/body/BodyRecord.java b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecord.java index d81c737..36fd102 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/BodyRecord.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecord.java @@ -4,7 +4,7 @@ package blockchain.body; * BodyRecord_new — общий контракт для всех типов body (тела блока). * * Идея: - * - На каждый тип body (Header, Text, File, ...) — отдельный класс. + * - На каждый тип body (Header, Text, Reaction, ...) — отдельный класс. * - Десериализация из байтов делается КОНСТРУКТОРОМ: * new XxxBody_new(byte[] bodyBytes) * (конструктор обязан распарсить байты или кинуть IllegalArgumentException). @@ -18,21 +18,28 @@ package blockchain.body; * * - type() и version() — это идентификаторы формата body. * Они должны быть константами для класса (например TYPE=1, VERSION=1). + * + * ДОПОЛНЕНИЕ (ЛИНИИ): + * - Каждый тип body знает, в какой lineIndex он ДОЛЖЕН находиться. + * Это проверяется в валидаторе блока (уровень B). */ public interface BodyRecord { - /** Код типа записи (совпадает с recordType в BchBlockEntry). */ + /** Код типа записи (совпадает с type в bodyBytes). */ short type(); - /** Версия формата записи (совпадает с recordTypeVersion в BchBlockEntry). */ + /** Версия формата записи (совпадает с version в bodyBytes). */ short version(); + /** Ожидаемый индекс линии для этого body. */ + short expectedLineIndex(); + /** Проверить корректность содержимого и вернуть этот объект (или кинуть исключение). */ BodyRecord check(); /** * Сериализовать тело записи в байты (ровно то, что кладётся в block.body). - * Важно: НЕ включает общий заголовок блока (recordNumber/timestamp/type/version). + * Важно: включает type/version. */ byte[] toBytes(); -} +} \ No newline at end of file diff --git a/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser.java b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser.java index 60c2c7e..7e00167 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser.java @@ -18,8 +18,9 @@ public final class BodyRecordParser { int key = ((type & 0xFFFF) << 16) | (ver & 0xFFFF); return switch (key) { - case 0x0000_0001 -> new HeaderBody(bodyBytes); // type=0, ver=1 - case 0x0001_0001 -> new TextBody(bodyBytes); // type=1, ver=1 + case 0x0000_0001 -> new HeaderBody(bodyBytes); // type=0, ver=1 + case 0x0001_0001 -> new TextBody(bodyBytes); // type=1, ver=1 + case 0x0002_0001 -> new ReactionBody(bodyBytes); // type=2, ver=1 default -> throw new IllegalArgumentException(String.format( "Unknown body type/version: type=%d ver=%d (key=0x%08X)", (type & 0xFFFF), (ver & 0xFFFF), key diff --git a/shine-server-blockchain/src/main/java/blockchain/body/HeaderBody.java b/shine-server-blockchain/src/main/java/blockchain/body/HeaderBody.java index 5c37d71..e6ae7f2 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/HeaderBody.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/HeaderBody.java @@ -14,6 +14,9 @@ import java.util.Objects; * [8] tag ASCII "SHiNE001" * [1] loginLength=N (uint8) * [N] login UTF-8 + * + * ЛИНИЯ: + * - строго lineIndex=0 (genesis) */ public final class HeaderBody implements BodyRecord { @@ -64,6 +67,11 @@ public final class HeaderBody implements BodyRecord { @Override public short type() { return TYPE; } @Override public short version() { return VER; } + @Override + public short expectedLineIndex() { + return 0; + } + @Override public HeaderBody check() { if (login == null || login.isBlank()) diff --git a/shine-server-blockchain/src/main/java/blockchain/body/ReactionBody.java b/shine-server-blockchain/src/main/java/blockchain/body/ReactionBody.java new file mode 100644 index 0000000..1518f63 --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/body/ReactionBody.java @@ -0,0 +1,139 @@ +package blockchain.body; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** + * ReactionBody — type=2, version=1. + * + * Сериализация bodyBytes: + * [2] type=2 + * [2] ver=1 + * [4] reactionCode (int32) + * [1] toBlockchainNameLen (uint8) + * [N] toBlockchainName UTF-8 + * [4] toBlockGlobalNumber (int32) + * [32] toBlockHash (raw 32 bytes) + * + * ЛИНИЯ: + * - строго lineIndex=2 + * + * ВАЖНО: + * - Здесь мы НЕ проверяем, существует ли цель реакции (MVP правило). + */ +public final class ReactionBody implements BodyRecord { + + public static final short TYPE = 2; + public static final short VER = 1; + + public final int reactionCode; + public final String toBlockchainName; + public final int toBlockGlobalNumber; + public final byte[] toBlockHash32; + + /** Десериализация из полного bodyBytes (включая type/version). */ + public ReactionBody(byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + if (bodyBytes.length < 4 + 4 + 1 + 1 + 4 + 32) { + throw new IllegalArgumentException("ReactionBody too short"); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + + short type = bb.getShort(); + short ver = bb.getShort(); + if (type != TYPE || ver != VER) + throw new IllegalArgumentException("Not ReactionBody: type=" + type + " ver=" + ver); + + this.reactionCode = bb.getInt(); + + int nameLen = Byte.toUnsignedInt(bb.get()); + if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); + if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short"); + + byte[] nameBytes = new byte[nameLen]; + bb.get(nameBytes); + this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); + + this.toBlockGlobalNumber = bb.getInt(); + + this.toBlockHash32 = new byte[32]; + bb.get(this.toBlockHash32); + } + + public ReactionBody(int reactionCode, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) { + Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); + if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + + this.reactionCode = reactionCode; + this.toBlockchainName = toBlockchainName; + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + } + + @Override public short type() { return TYPE; } + @Override public short version() { return VER; } + + @Override + public short expectedLineIndex() { + return 2; + } + + @Override + public ReactionBody check() { + if (toBlockchainName == null || toBlockchainName.isBlank()) + throw new IllegalArgumentException("toBlockchainName is blank"); + byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (nameBytes.length == 0 || nameBytes.length > 255) + throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); + + if (toBlockGlobalNumber < 0) + throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("toBlockHash32 invalid"); + + return this; + } + + @Override + public byte[] toBytes() { + byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (nameBytes.length == 0 || nameBytes.length > 255) + throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); + + int cap = 4 + 4 + 1 + nameBytes.length + 4 + 32; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.putShort(TYPE); + bb.putShort(VER); + bb.putInt(reactionCode); + bb.put((byte) nameBytes.length); + bb.put(nameBytes); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + + return bb.array(); + } + + /** Для записи в БД (toBlockHashe TEXT) удобно хранить hex. */ + public String toBlockHashHex() { + return toHex(toBlockHash32); + } + + private static String toHex(byte[] bytes) { + char[] HEX = "0123456789abcdef".toCharArray(); + char[] out = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + int v = bytes[i] & 0xFF; + out[i * 2] = HEX[v >>> 4]; + out[i * 2 + 1] = HEX[v & 0x0F]; + } + return new String(out); + } +} \ No newline at end of file diff --git a/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java b/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java index b637256..41529fb 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java @@ -7,6 +7,17 @@ import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.Objects; +/** + * TextBody — type=1, ver=1. + * + * bodyBytes: + * [2] type=1 + * [2] ver=1 + * [N] utf8 message + * + * ЛИНИЯ: + * - строго lineIndex=1 + */ public final class TextBody implements BodyRecord { public static final short TYPE = 1; @@ -53,6 +64,11 @@ public final class TextBody implements BodyRecord { @Override public short type() { return TYPE; } @Override public short version() { return VER; } + @Override + public short expectedLineIndex() { + return 1; + } + @Override public TextBody check() { if (message == null || message.isBlank()) diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/BlockchainWriter.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/BlockchainWriter.java index 39deca7..dfb9b11 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/BlockchainWriter.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/BlockchainWriter.java @@ -1,6 +1,7 @@ package server.logic.ws_protocol.JSON.handlers.blockchain; import blockchain.BchBlockEntry; +import blockchain.body.ReactionBody; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import shine.db.SqliteDbController; @@ -8,6 +9,7 @@ import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlocksDAO; import shine.db.entities.BlockEntry; import shine.db.entities.BlockchainStateEntry; +import utils.blockchain.BlockchainNameUtil; import utils.files.FileStoreUtil; import shine.log.BlockchainAdminNotifier; @@ -61,47 +63,32 @@ public final class BlockchainWriter { String login, String blockchainName, String prevGlobalHashHex, + String prevLineHashHex, BchBlockEntry block, BlockchainStateEntry stOrNull, String newHashHex ) throws SQLException { - // ===================================================================== - // ШАГ 0. КРИТИЧЕСКАЯ ПРОВЕРКА КОНСИСТЕНТНОСТИ: - // - если state есть и ожидает ненулевой размер, - // то основной файл должен существовать и иметь точно этот размер. - // - если не так — это почти наверняка внешнее вмешательство/порча, - // и продолжать запись НЕЛЬЗЯ. - // ===================================================================== verifyMainFileSizeMatchesStateOrAlert(login, blockchainName, block, stOrNull); // ===================================================================== // ШАГ 1. Готовим bytes нового блока (включая signature+hash) // ===================================================================== - final byte[] newBlockFullBytes = block.toBytes(); // ✅ включает хвост signature+hash + final byte[] newBlockFullBytes = block.toBytes(); // ===================================================================== // ШАГ 2. Считаем новый fileSizeBytes - // - если genesis (state == null): старый размер = 0 - // - иначе берём st.fileSizeBytes // ===================================================================== final long oldFileSize = (stOrNull == null) ? 0L : stOrNull.getFileSizeBytes(); final long newFileSize = safeAdd(oldFileSize, newBlockFullBytes.length); // ===================================================================== - // ШАГ 3. Создаём новый tmp-файл: - // tmp = (old file bytes) + (new block bytes) - // - // Важно: - // - читаем старый файл ТОЛЬКО если state не null и size > 0 - // - если genesis: старого файла нет => tmp = newBlock + // ШАГ 3. Создаём новый tmp-файл: tmp = (old file bytes) + (new block bytes) // ===================================================================== final byte[] tmpBytes; if (stOrNull == null || oldFileSize == 0) { - // genesis: tmp = только новый блок tmpBytes = newBlockFullBytes; } else { - // не genesis: tmp = старый файл + новый блок byte[] oldBytes; try { oldBytes = fs.readBlockchain(blockchainName); @@ -111,7 +98,6 @@ public final class BlockchainWriter { throw new SQLException("Cannot read old blockchain file for: " + blockchainName, e); } - // (в идеале это всегда должно совпадать после verifyMainFileSizeMatchesStateOrAlert) if (oldBytes.length != (int) oldFileSize) { String msg = "Несовпадение размера файла блокчейна при чтении: " + @@ -127,8 +113,6 @@ public final class BlockchainWriter { tmpBytes = concat(oldBytes, newBlockFullBytes); } - // Пишем tmp на диск ДО транзакции БД: - // - если сервер упадёт позже — tmp останется, но БД может не успеть обновиться (это ок для recovery) try { fs.writeBlockchainTmp(blockchainName, tmpBytes); } catch (Exception e) { @@ -138,9 +122,7 @@ public final class BlockchainWriter { } // ===================================================================== - // ШАГ 4. АТОМАРНО фиксируем БД: - // - UPSERT blocks - // - UPSERT blockchain_state (включая fileSizeBytes = newFileSize) + // ШАГ 4. АТОМАРНО фиксируем БД // ===================================================================== try (Connection c = db.getConnection()) { @@ -150,21 +132,18 @@ public final class BlockchainWriter { boolean committed = false; try { - // 4.1) вставляем/апдейтим запись блока - insertBlockRow(c, login, blockchainName, prevGlobalHashHex, block); + insertBlockRow(c, login, blockchainName, prevGlobalHashHex, prevLineHashHex, block); - // 4.2) апдейтим состояние (включая fileSizeBytes) - appendState(c, blockchainName, block.recordNumber, stOrNull, newHashHex, newFileSize); + appendState(c, blockchainName, block, stOrNull, newHashHex, newFileSize); - // 4.3) commit c.commit(); committed = true; } catch (Exception e) { try { c.rollback(); } catch (SQLException ignore) {} - log.error("Ошибка транзакции БД при добавлении блока (rollback выполнен) (login={}, blockchainName={}, blockNumber={}, prevHash={}, newHash={}, oldFileSize={}, newFileSize={})", - login, blockchainName, block.recordNumber, prevGlobalHashHex, newHashHex, oldFileSize, newFileSize, e); + log.error("Ошибка транзакции БД при добавлении блока (rollback выполнен) (login={}, blockchainName={}, blockNumber={}, prevGlobalHash={}, prevLineHash={}, newHash={}, oldFileSize={}, newFileSize={})", + login, blockchainName, block.recordNumber, prevGlobalHashHex, prevLineHashHex, newHashHex, oldFileSize, newFileSize, e); if (e instanceof SQLException se) throw se; throw new SQLException("appendBlockAndState failed (db tx)", e); @@ -174,8 +153,7 @@ public final class BlockchainWriter { } // ================================================================= - // ШАГ 5. После успешного коммита БД — атомарно заменяем файл: - // .tmp_bch -> .bch + // ШАГ 5. После успешного коммита БД — атомарно заменяем файл // ================================================================= if (committed) { try { @@ -193,10 +171,6 @@ public final class BlockchainWriter { } } - /** - * Проверка: реальный размер .bch должен совпадать с st.fileSizeBytes. - * Если нет — это критическая внешняя порча/вмешательство, уведомляем админа и падаем. - */ private void verifyMainFileSizeMatchesStateOrAlert( String login, String blockchainName, @@ -204,21 +178,13 @@ public final class BlockchainWriter { BlockchainStateEntry stOrNull ) throws SQLException { - if (stOrNull == null) { - // genesis — state ещё нет, проверять нечего - return; - } + if (stOrNull == null) return; long expected = stOrNull.getFileSizeBytes(); - if (expected <= 0) { - // state есть, но ожидаемый размер 0 — это либо пустая цепочка, либо старый формат. - // Здесь не трогаем (но можно усилить правила позже). - return; - } + if (expected <= 0) return; String mainFileName = fs.buildBlockchainFileName(blockchainName); - // Если файла нет — это уже очень подозрительно: state говорит “файл есть и размер > 0” if (!fs.exists(mainFileName)) { String msg = "КРИТИЧЕСКАЯ ОШИБКА КОНСИСТЕНТНОСТИ: state ожидает основной файл, но его нет. " + @@ -262,14 +228,16 @@ public final class BlockchainWriter { /** * Обновление состояния blockchain_state (создаём если отсутствует). - * Пока линии не используются: lineIndex=0 и lineHash = globalHash. * - * + обновляем fileSizeBytes + * ПРАВИЛО ЛИНИЙ (как ты описал): + * - globalNumber=0 — genesis в lineIndex=0, lineNumber=0, и его hash — базовый для ВСЕХ линий. + * - для lineIndex>0 первая запись имеет lineNumber=1, её prevLineHash = hash(genesis) + * - lastLineNumber/lastLineHash ведём независимо по каждой линии. */ private void appendState( Connection c, String blockchainName, - int globalNumber, + BchBlockEntry block, BlockchainStateEntry stOrNull, String newHashHex, long newFileSizeBytes @@ -281,32 +249,38 @@ public final class BlockchainWriter { st.setBlockchainName(blockchainName); } - // Последний глобальный блок - st.setLastGlobalNumber(globalNumber); + // глобальная цепочка всегда растёт по recordNumber + st.setLastGlobalNumber(block.recordNumber); st.setLastGlobalHash(newHashHex); - // Линии пока не используются - st.setLastLineNumber(0, globalNumber); - st.setLastLineHash(0, newHashHex); + // обновляем конкретную линию блока + int li = block.lineIndex; + st.setLastLineNumber(li, block.lineNumber); + st.setLastLineHash(li, newHashHex); - // ✅ ВАЖНО: сохраняем ожидаемый размер файла + // file size st.setFileSizeBytes(newFileSizeBytes); - // Метка времени обновления + // timestamp st.setUpdatedAtMs(System.currentTimeMillis()); - // UPSERT stateDAO.upsert(c, st); } /** * Вставка/апдейт строки блока в blocks. + * + * Важно: + * - blockLinePreHashe = prevLineHashHex (а НЕ prevGlobalHashHex) + * - msgType = body.type() + * - Для ReactionBody заполняем toBchName/toBlockGlobalNumber/toBlockHashe (+ to_login если можем). */ private void insertBlockRow( Connection c, String login, String blockchainName, String prevGlobalHashHex, + String prevLineHashHex, BchBlockEntry block ) throws SQLException { @@ -318,22 +292,33 @@ public final class BlockchainWriter { e.setBlockGlobalNumber(block.recordNumber); e.setBlockGlobalPreHashe(prevGlobalHashHex); - // линии пока не используем - e.setBlockLineIndex(0); - e.setBlockLineNumber(block.recordNumber); - e.setBlockLinePreHashe(prevGlobalHashHex); + e.setBlockLineIndex(block.lineIndex); + e.setBlockLineNumber(block.lineNumber); + e.setBlockLinePreHashe(prevLineHashHex); - // тип сообщения — по body.type() e.setMsgType(block.body.type()); - // полный блок (RAW + signature + hash) e.setBlockByte(block.toBytes()); + // defaults e.setToLogin(null); e.setToBchName(null); e.setToBlockGlobalNumber(null); e.setToBlockHashe(null); + // ReactionBody -> target fields + if (block.body instanceof ReactionBody rb) { + e.setToBchName(rb.toBlockchainName); + e.setToBlockGlobalNumber(rb.toBlockGlobalNumber); + e.setToBlockHashe(rb.toBlockHashHex()); + + // optional: try compute to_login from target chain name (для индекса idx_blocks_to_target) + String toLogin = BlockchainNameUtil.loginFromBlockchainName(rb.toBlockchainName); + if (toLogin != null && !toLogin.isBlank()) { + e.setToLogin(toLogin); + } + } + blocksDAO.upsert(c, e); } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 35de9e6..a1b8a0f 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -27,22 +27,23 @@ import java.util.concurrent.locks.ReentrantLock; * Задачи: * 1) Лочим добавление блоков для конкретного blockchainName (защита от гонок в одном процессе). * 2) Декодируем блок из Base64 и парсим его структуру. - * 3) Парсим body и валидируем (type/version + содержимое). + * 3) Валидируем body (type/version + содержимое). * 4) Проверяем globalNumber и prevGlobalHash относительно server state. - * 5) Проверяем подпись/хэш (Ed25519 над hash32, hash32=sha256(preimage)). - * 6) Делаем запись в БД через BlockchainDbWriter (атомарность реализуется там). - * 7) Возвращаем клиенту serverLastGlobalNumber/serverLastGlobalHash. + * 5) Проверяем линии: + * - genesis: global=0, lineIndex=0, lineNumber=0 + * - остальные: lineIndex=1..7, lineNumber по счётчику линии + * 6) Проверяем подпись/хэш (Ed25519 над hash32, hash32=sha256(preimage)). + * preimage включает prevLineHash32 (берём из state по lineIndex). + * 7) Пишем в БД+файл через BlockchainWriter (атомарность там). */ public final class Net_AddBlock_Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_AddBlock_Handler.class); - // DAO (перегрузки сами создают/закрывают Connection внутри) private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); private final SolanaUsersDAO solanaUsersDAO = SolanaUsersDAO.getInstance(); - // Writer отвечает за транзакции/атомарность и консистентность БД private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO); @Override @@ -50,17 +51,17 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { Net_AddBlock_Request req = (Net_AddBlock_Request) baseReq; - // 0) Берём имя цепочки и лочим операции добавления для неё String blockchainName = req.getBlockchainName(); ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); lock.lock(); try { - AddBlockResult r = addBlock(blockchainName, + AddBlockResult r = addBlock( + blockchainName, req.getGlobalNumber(), req.getPrevGlobalHash(), - req.getBlockBytesB64()); + req.getBlockBytesB64() + ); - // 7) Формируем стандартный Net_AddBlock_Response Net_AddBlock_Response resp = new Net_AddBlock_Response(); resp.setOp(req.getOp()); resp.setRequestId(req.getRequestId()); @@ -73,7 +74,6 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { resp.setReasonCode(r.reasonCode); } - // Возвращаем актуальное состояние сервера (даже при ошибках, где уместно) resp.setServerLastGlobalNumber(r.serverLastGlobalNumber); if (r.serverLastGlobalHash != null) { resp.setServerLastGlobalHash(r.serverLastGlobalHash); @@ -86,27 +86,17 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } } - /* ===================================================================== */ - /* ========================== Основная логика =========================== */ - /* ===================================================================== */ - - /** - * Внутренняя логика добавления блока (без ручного управления Connection/tx). - * Все атомарные записи — внутри BlockchainDbWriter. - */ private AddBlockResult addBlock( String blockchainName, int globalNumber, String prevGlobalHashHex, String blockBytesB64 ) { - // 1) Быстрая валидация входных параметров if (blockchainName == null || blockchainName.isBlank()) { log.warn("AddBlock: пустой blockchainName (globalNumber={})", globalNumber); return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "empty_blockchain_name", 0, ""); } - // 2) Из имени блокчейна вытаскиваем login (как ты и хотел — через util) String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName); if (login == null || login.isBlank()) { log.warn("AddBlock: плохой blockchainName='{}' => login не получился (globalNumber={})", @@ -114,7 +104,6 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_name", 0, ""); } - // 3) Декодируем блок из Base64 final byte[] blockBytes; try { blockBytes = decodeBase64(blockBytesB64); @@ -124,17 +113,17 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_base64", 0, ""); } - // 4) Парсим блок (проверяется recordSize и минимальная длина) final BchBlockEntry block; try { block = new BchBlockEntry(blockBytes); } catch (Exception e) { + // важно: BchBlockEntry теперь сам валит блок, если body в неправильной линии log.warn("AddBlock: не удалось распарсить BchBlockEntry (login={}, blockchainName={}, globalNumber={}, bytesLen={})", login, blockchainName, globalNumber, blockBytes.length, e); return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", 0, ""); } - // 5) Валидируем body (type/version + содержимое) — теперь body уже распарсен внутри BchBlockEntry + // body.check() try { block.body.check(); } catch (Exception e) { @@ -143,19 +132,18 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", 0, ""); } - // 6) Защита от рассинхрона: recordNumber внутри блока должен совпадать с заявленным globalNumber + // recordNumber == globalNumber if (block.recordNumber != globalNumber) { log.warn("AddBlock: global_number_mismatch (login={}, blockchainName={}, заявлен={}, внутриБлока={})", login, blockchainName, globalNumber, block.recordNumber); return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "global_number_mismatch", 0, ""); } - // 7) Получаем пользователя и его loginKey (публичный ключ 32 байта) + // user + pubkey SolanaUserEntry u; try { - u = solanaUsersDAO.getByLogin(login); // перегрузка: сама открывает/закрывает соединение + u = solanaUsersDAO.getByLogin(login); } catch (Exception e) { - // ✅ ВОТ ТУТ ТВОЯ ОШИБКА РАНЬШЕ ТЕРЯЛАСЬ: теперь будет stacktrace в логе log.error("AddBlock: ошибка БД при чтении пользователя (login={}, blockchainName={}, globalNumber={})", login, blockchainName, globalNumber, e); return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error", 0, ""); @@ -174,21 +162,21 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_user_login_key", 0, ""); } - // 8) Читаем текущее состояние блокчейна с сервера + // state BlockchainStateEntry st; try { - st = stateDAO.getByBlockchainName(blockchainName); // перегрузка: сама открывает/закрывает соединение + st = stateDAO.getByBlockchainName(blockchainName); } catch (Exception e) { - // ✅ ВОТ ТУТ ТВОЯ ОШИБКА РАНЬШЕ ТЕРЯЛАСЬ: теперь будет stacktrace в логе log.error("AddBlock: ошибка БД при чтении blockchain_state (login={}, blockchainName={}, globalNumber={})", login, blockchainName, globalNumber, e); return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error", 0, ""); } - // 9) Определяем serverLastNum/serverLastHash (если state ещё нет — ожидаем genesis с globalNumber=0) final int serverLastNum; final String serverLastHash; + if (st == null) { + // нет state => обязаны принимать genesis if (globalNumber != 0) { log.warn("AddBlock: blockchain_state_not_found, но globalNumber != 0 (login={}, blockchainName={}, globalNumber={})", login, blockchainName, globalNumber); @@ -201,15 +189,15 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { serverLastHash = nn(st.getLastGlobalHash()); } - // 10) Проверяем, что клиент присылает следующий блок ровно (last+1) - int expected = serverLastNum + 1; - if (globalNumber != expected) { + // следующий global строго + int expectedGlobal = serverLastNum + 1; + if (globalNumber != expectedGlobal) { log.warn("AddBlock: bad_global_number (login={}, blockchainName={}, пришёл={}, ожидали={}, serverLastNum={}, serverLastHash={})", - login, blockchainName, globalNumber, expected, serverLastNum, serverLastHash); + login, blockchainName, globalNumber, expectedGlobal, serverLastNum, serverLastHash); return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_global_number", serverLastNum, serverLastHash); } - // 11) Проверяем prevGlobalHash: клиент должен ссылаться на текущий serverLastHash + // prevGlobalHash сравниваем со state.lastGlobalHash final byte[] prevGlobalHash32; final byte[] serverPrevGlobal32; try { @@ -227,60 +215,110 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_global_hash", serverLastNum, serverLastHash); } - // 12) Пока линии не используем — prevLineHash равен prevGlobalHash (как ты писал) - byte[] prevLineHash32 = prevGlobalHash32; + // =========================== + // ЛИНИИ (строго) + // =========================== - // 13) Криптопроверка: hash в блоке + подпись над hash + int li = block.lineIndex; + int ln = block.lineNumber; + + if (globalNumber == 0) { + // genesis + if (li != 0 || ln != 0) { + log.warn("AddBlock: bad_genesis_line_fields (login={}, blockchainName={}, lineIndex={}, lineNumber={})", + login, blockchainName, li, ln); + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_genesis_line_fields", serverLastNum, serverLastHash); + } + } else { + // MVP: запрещаем lineIndex=0 для не-genesis (чтобы техблоки не пролезли случайно) + if (li == 0) { + log.warn("AddBlock: line0_only_genesis (login={}, blockchainName={}, globalNumber={}, lineIndex={})", + login, blockchainName, globalNumber, li); + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "line0_only_genesis", serverLastNum, serverLastHash); + } + if (li < 1 || li > 7) { + log.warn("AddBlock: bad_line_index (login={}, blockchainName={}, globalNumber={}, lineIndex={})", + login, blockchainName, globalNumber, li); + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_line_index", serverLastNum, serverLastHash); + } + + if (st == null) { + // теоретически сюда не должны попасть (global>0 при st==null уже отфутболили) + return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_state_error", serverLastNum, serverLastHash); + } + + int expectedLineNumber = st.getLastLineNumber(li) + 1; + if (ln != expectedLineNumber) { + log.warn("AddBlock: bad_line_number (login={}, blockchainName={}, globalNumber={}, lineIndex={}, пришёлLineNumber={}, ожидалиLineNumber={}, lastLineNumber={})", + login, blockchainName, globalNumber, li, ln, expectedLineNumber, st.getLastLineNumber(li)); + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_line_number", serverLastNum, serverLastHash); + } + } + + // prevLineHash берём из state по lineIndex: + // - genesis: 32 нулей + // - иначе: st.getLastLineHash(li) (для первой записи в линии это будет hash genesis) + final byte[] prevLineHash32; + final String prevLineHashHex; + try { + if (st == null) { + prevLineHash32 = new byte[32]; + prevLineHashHex = ""; + } else { + prevLineHashHex = nn(st.getLastLineHash(li)); + prevLineHash32 = hexTo32(prevLineHashHex); + } + } catch (Exception e) { + log.warn("AddBlock: bad_prev_line_hash_in_state (login={}, blockchainName={}, globalNumber={}, lineIndex={})", + login, blockchainName, globalNumber, li, e); + return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "bad_prev_line_hash_in_state", serverLastNum, serverLastHash); + } + + // crypto verify boolean ok = BchCryptoVerifier.verifyAll( login, prevGlobalHash32, prevLineHash32, - block.getRawBytes(), // только RAW (без signature/hash) - block.getSignature64(), // подпись Ed25519 - loginKey32, // public key пользователя - block.getHash32() // ожидаемый hash32 из самого блока + block.getRawBytes(), + block.getSignature64(), + loginKey32, + block.getHash32() ); if (!ok) { - log.warn("AddBlock: bad_signature_or_hash (login={}, blockchainName={}, globalNumber={})", - login, blockchainName, globalNumber); + log.warn("AddBlock: bad_signature_or_hash (login={}, blockchainName={}, globalNumber={}, lineIndex={}, lineNumber={})", + login, blockchainName, globalNumber, li, ln); return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature_or_hash", serverLastNum, serverLastHash); } - // 14) Новый hash блока (hex) — то, что будет записано как lastGlobalHash String newHashHex = toHex(block.getHash32()); - // 15) Запись блока + обновление состояния (атомарность/транзакции — внутри dbWriter) + // write try { dbWriter.appendBlockAndState( login, blockchainName, nn(prevGlobalHashHex), - block, // ✅ передаём целиком объект блока + prevLineHashHex, + block, st, newHashHex ); } catch (Exception e) { - // ✅ ВОТ ЭТО САМОЕ ВАЖНОЕ: если упал writer/БД/файлы — теперь будет stacktrace в логах log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, globalNumber={}, newHash={})", login, blockchainName, globalNumber, newHashHex, e); return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHash); } - // 16) Успех - log.info("✅ AddBlock ok: login={}, blockchainName={}, globalNumber={}, newHash={}", - login, blockchainName, globalNumber, newHashHex); + log.info("✅ AddBlock ok: login={}, blockchainName={}, globalNumber={}, lineIndex={}, lineNumber={}, newHash={}", + login, blockchainName, globalNumber, li, ln, newHashHex); + return new AddBlockResult(WireCodes.Status.OK, null, globalNumber, newHashHex); } - /* ===================================================================== */ - /* ============================= Result ================================= */ - /* ===================================================================== */ - - /** Результат обработки addBlock */ private static final class AddBlockResult { - final int httpStatus; // WireCodes.Status.* - final String reasonCode; // null если ok + final int httpStatus; + final String reasonCode; final int serverLastGlobalNumber; final String serverLastGlobalHash; @@ -296,10 +334,6 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } } - /* ===================================================================== */ - /* ============================== Utils ================================= */ - /* ===================================================================== */ - private static String nn(String s) { return s == null ? "" : s; } private static byte[] decodeBase64(String s) {