From ca55bfca9305ea16b4973300479f0bc62e4f7a155de1f17240119ddc6c5f0fda Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 2 Jan 2026 16:42:15 +0300 Subject: [PATCH] =?UTF-8?q?02=2001=2025=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BF=D0=BE=D0=BB=D0=B5=20subType=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D0=BA=D0=B8=D0=B5=20=D0=B1=D0=B0=D0=B3=D0=B8=20(=D0=B2?= =?UTF-8?q?=D1=81=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D1=8E=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дальше делать: Описание форматов. Запросы клиент-сервер. Промт на клиента. --- Потом в сервак дописать Синхронизацию серверов. --- .../main/java/blockchain/body/BodyRecord.java | 13 +- .../main/java/blockchain/body/HeaderBody.java | 45 ++- .../java/blockchain/body/ReactionBody.java | 72 ++++- .../main/java/blockchain/body/TextBody.java | 298 ++++++++++++++++-- .../java/test/it/IT_03_AddBlock_NoAuth.java | 2 +- .../test/it/addBlockUtils/AddBlockFlow.java | 4 +- 6 files changed, 386 insertions(+), 48 deletions(-) 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 36fd102..9636312 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/BodyRecord.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecord.java @@ -22,6 +22,12 @@ package blockchain.body; * ДОПОЛНЕНИЕ (ЛИНИИ): * - Каждый тип body знает, в какой lineIndex он ДОЛЖЕН находиться. * Это проверяется в валидаторе блока (уровень B). + * + * ДОПОЛНЕНИЕ (SUBTYPE): + * - У каждого body есть subType (uint16). + * - Для HeaderBody он всегда 0 (служебная совместимость). + * - Для TextBody это тип сообщения (NEW/REPLY/REPOST). + * - Для ReactionBody это тип реакции (LIKE и т.п.). */ public interface BodyRecord { @@ -31,6 +37,11 @@ public interface BodyRecord { /** Версия формата записи (совпадает с version в bodyBytes). */ short version(); + /** + * Подтип записи (uint16). + */ + short subType(); + /** Ожидаемый индекс линии для этого body. */ short expectedLineIndex(); @@ -39,7 +50,7 @@ public interface BodyRecord { /** * Сериализовать тело записи в байты (ровно то, что кладётся в block.body). - * Важно: включает type/version. + * Важно: включает type/version/subType и весь payload. */ byte[] toBytes(); } \ No newline at end of file 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 2035e3e..6ac7711 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/HeaderBody.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/HeaderBody.java @@ -8,10 +8,15 @@ import java.util.Objects; /** * HeaderBody — type=0, version=1. * - * Полный bodyBytes: + * Полный bodyBytes (BigEndian): * [2] type=0 * [2] version=1 - * [8] tag ASCII "SHiNE" + * + * [2] subType (uint16) = 0 + * (служебное поле для совместимости с единым форматом body, + * чтобы ВСЕ body имели subType одинаковым способом) + * + * [5] tag ASCII "SHiNE" * [1] loginLength=N (uint8) * [N] login UTF-8 * @@ -23,23 +28,33 @@ public final class HeaderBody implements BodyRecord { public static final short TYPE = 0; public static final short VER = 1; + /** Для header всегда 0 (служебная совместимость). */ + public static final short SUBTYPE_COMPAT = 0; + public static final String TAG = "SHiNE"; - public final String tag; // "SHiNE" + public final short subType; // всегда 0 + public final String tag; // "SHiNE" public final String login; - /** Десериализация из полного bodyBytes (включая type/version). */ + /** Десериализация из полного bodyBytes (включая type/version/subType). */ public HeaderBody(byte[] bodyBytes) { Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - if (bodyBytes.length < 4) throw new IllegalArgumentException("HeaderBody too short (<4)"); + if (bodyBytes.length < 4 + 2) throw new IllegalArgumentException("HeaderBody too short (<6)"); 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 HeaderBody: type=" + type + " ver=" + ver); - if (bb.remaining() < 8 + 1) + this.subType = bb.getShort(); + if (this.subType != SUBTYPE_COMPAT) + throw new IllegalArgumentException("HeaderBody subType must be 0, got=" + (this.subType & 0xFFFF)); + + // дальше: tag[5] + loginLen[1] минимум + if (bb.remaining() < 5 + 1) throw new IllegalArgumentException("Header payload too short"); byte[] tagBytes = new byte[5]; @@ -55,17 +70,24 @@ public final class HeaderBody implements BodyRecord { byte[] loginBytes = new byte[loginLen]; bb.get(loginBytes); this.login = new String(loginBytes, StandardCharsets.UTF_8); + + // запрещаем мусор в конце + if (bb.remaining() != 0) { + throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); + } } /** Создание “вручную” (для генерации первого блока). */ public HeaderBody(String login) { Objects.requireNonNull(login, "login == null"); + this.subType = SUBTYPE_COMPAT; this.tag = TAG; this.login = login; } @Override public short type() { return TYPE; } @Override public short version() { return VER; } + @Override public short subType() { return subType; } @Override public short expectedLineIndex() { @@ -74,6 +96,9 @@ public final class HeaderBody implements BodyRecord { @Override public HeaderBody check() { + if (subType != SUBTYPE_COMPAT) + throw new IllegalArgumentException("HeaderBody subType must be 0"); + if (login == null || login.isBlank()) throw new IllegalArgumentException("Login is blank"); if (!login.matches("^[A-Za-z0-9_]+$")) @@ -87,14 +112,17 @@ public final class HeaderBody implements BodyRecord { if (loginUtf8.length > 255) throw new IllegalArgumentException("Login too long (>255 bytes)"); - int cap = 4 + 8 + 1 + loginUtf8.length; + // type[2] + ver[2] + subType[2] + tag[5] + loginLen[1] + login[N] + int cap = 2 + 2 + 2 + 5 + 1 + loginUtf8.length; ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.putShort(TYPE); bb.putShort(VER); - bb.put(TAG.getBytes(StandardCharsets.US_ASCII)); // [8] + bb.putShort(SUBTYPE_COMPAT); + + bb.put(TAG.getBytes(StandardCharsets.US_ASCII)); // [5] bb.put((byte) loginUtf8.length); // [1] bb.put(loginUtf8); // [N] @@ -107,6 +135,7 @@ public final class HeaderBody implements BodyRecord { HeaderBody { тип записи : HEADER (type=0, ver=1) ожидаемая линия : 0 (genesis) + subType : 0 (compat) тег формата : "%s" login владельца : "%s" } diff --git a/shine-server-blockchain/src/main/java/blockchain/body/ReactionBody.java b/shine-server-blockchain/src/main/java/blockchain/body/ReactionBody.java index d8595a5..abf6fc9 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/ReactionBody.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/ReactionBody.java @@ -9,34 +9,46 @@ import java.util.Objects; /** * ReactionBody — type=2, version=1. * - * Сериализация bodyBytes: + * Формат bodyBytes (BigEndian): * [2] type=2 * [2] ver=1 - * [4] reactionCode (int32) + * + * [2] subType (uint16) — подтип реакции (раньше это был reactionCode int32) + * 1 = LIKE (лайк) + * (в будущем: 2=DISLIKE, 3=LAUGH, 4=WOW ... если захочешь) + * * [1] toBlockchainNameLen (uint8) * [N] toBlockchainName UTF-8 * [4] toBlockGlobalNumber (int32) - * [32] toBlockHash (raw 32 bytes) + * [32] toBlockHash32 (raw 32 bytes) * * ЛИНИЯ: * - строго lineIndex=2 * - * ВАЖНО: - * - Здесь мы НЕ проверяем, существует ли цель реакции (MVP правило). + * ВАЖНО (MVP): + * - Здесь мы НЕ проверяем, существует ли цель реакции. + * - Мы проверяем только корректность формата и целостность полей. */ public final class ReactionBody implements BodyRecord { public static final short TYPE = 2; public static final short VER = 1; - public final int reactionCode; + // subType: + public static final short SUB_LIKE = 1; + + public final short subType; + public final String toBlockchainName; public final int toBlockGlobalNumber; public final byte[] toBlockHash32; + /** Десериализация из полного bodyBytes (включая type/version/subType). */ public ReactionBody(byte[] bodyBytes) { Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - if (bodyBytes.length < 4 + 4 + 1 + 1 + 4 + 32) { + + // минимум: type[2]+ver[2]+subType[2]+nameLen[1]+name[1]+global[4]+hash[32] + if (bodyBytes.length < 2 + 2 + 2 + 1 + 1 + 4 + 32) { throw new IllegalArgumentException("ReactionBody too short"); } @@ -47,11 +59,15 @@ public final class ReactionBody implements BodyRecord { if (type != TYPE || ver != VER) throw new IllegalArgumentException("Not ReactionBody: type=" + type + " ver=" + ver); - this.reactionCode = bb.getInt(); + this.subType = bb.getShort(); + if (this.subType != SUB_LIKE) { + throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF)); + } 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"); + if (bb.remaining() < nameLen + 4 + 32) + throw new IllegalArgumentException("ReactionBody payload too short"); byte[] nameBytes = new byte[nameLen]; bb.get(nameBytes); @@ -61,15 +77,30 @@ public final class ReactionBody implements BodyRecord { this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32); + + // запрет мусора в конце + if (bb.remaining() != 0) { + throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); + } } - public ReactionBody(int reactionCode, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) { + /** Создание “вручную”. */ + public ReactionBody(short subType, + String toBlockchainName, + int toBlockGlobalNumber, + byte[] toBlockHash32) { + Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + + if (subType != SUB_LIKE) + throw new IllegalArgumentException("Unknown reaction subType: " + (subType & 0xFFFF)); + if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); - this.reactionCode = reactionCode; + this.subType = subType; this.toBlockchainName = toBlockchainName; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); @@ -77,6 +108,7 @@ public final class ReactionBody implements BodyRecord { @Override public short type() { return TYPE; } @Override public short version() { return VER; } + @Override public short subType() { return subType; } @Override public short expectedLineIndex() { @@ -85,12 +117,16 @@ public final class ReactionBody implements BodyRecord { @Override public ReactionBody check() { + if (subType != SUB_LIKE) + throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF)); + if (toBlockchainName == null || toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); + return this; } @@ -100,12 +136,16 @@ public final class ReactionBody implements BodyRecord { 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; + // type[2]+ver[2]+subType[2] + nameLen[1]+name[N] + global[4] + hash[32] + int cap = 2 + 2 + 2 + 1 + nameBytes.length + 4 + 32; ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.putShort(TYPE); bb.putShort(VER); - bb.putInt(reactionCode); + + bb.putShort(subType); + bb.put((byte) nameBytes.length); bb.put(nameBytes); bb.putInt(toBlockGlobalNumber); @@ -116,17 +156,19 @@ public final class ReactionBody implements BodyRecord { @Override public String toString() { + String st = (subType == SUB_LIKE) ? "LIKE (1)" : "UNKNOWN"; + return """ ReactionBody { тип записи : REACTION (type=2, ver=1) ожидаемая линия : 2 - код реакции : %d + subType : %s целевой блокчейн : "%s" globalNumber цели : %d hash цели (hex) : %s } """.formatted( - reactionCode, + st, toBlockchainName, toBlockGlobalNumber, toBlockHashHex() 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 1962a74..30cb56e 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java @@ -5,39 +5,97 @@ import java.nio.ByteOrder; import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.Objects; /** * TextBody — type=1, ver=1. * - * bodyBytes: + * Формат bodyBytes (BigEndian): * [2] type=1 * [2] ver=1 - * [N] utf8 message + * + * [2] subType (uint16): подтип текстового сообщения + * 1 = новое сообщение (начало ветки) + * 2 = ответ на сообщение (reply) + * 3 = репост (repost) + * + * [2] textLenBytes (uint16) — длина текста в байтах UTF-8 + * [N] text UTF-8 + * + * Далее ТОЛЬКО если subType == 2 или subType == 3: + * [1] toBlockchainNameLen (uint8) + * [N] toBlockchainName UTF-8 + * [4] toBlockGlobalNumber (int32) + * [32] toBlockHash32 (raw 32 bytes) * * ЛИНИЯ: * - строго lineIndex=1 + * + * Правила строгого парсинга (чтобы формат не “плыл”): + * - subType обязан быть 1/2/3 + * - textLen обязан быть >0 и <=65535 + * - text обязан быть валидным UTF-8 и не blank + * - для subType=NEW запрещены поля ссылки и запрещены любые “лишние байты” в хвосте + * - для subType=REPLY/REPOST хвост обязан быть ровно по формату и без мусора в конце */ public final class TextBody implements BodyRecord { public static final short TYPE = 1; public static final short VER = 1; + // subType: + public static final short SUB_NEW = 1; + public static final short SUB_REPLY = 2; + public static final short SUB_REPOST = 3; + + /** Подтип текстового сообщения (1/2/3). */ + public final short subType; + + /** Текст сообщения (строго валидный UTF-8, не пустой/не blank). */ public final String message; + // Заполняются только если subType == SUB_REPLY или SUB_REPOST + public final String toBlockchainName; + public final int toBlockGlobalNumber; + public final byte[] toBlockHash32; + + /* ===================================================================== */ + /* ====================== Конструктор из байт =========================== */ + /* ===================================================================== */ + + /** Десериализация из полного bodyBytes (включая type/version). */ public TextBody(byte[] bodyBytes) { Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - if (bodyBytes.length < 5) + + // минимум: type+ver (4) + subType(2) + textLen(2) + if (bodyBytes.length < 4 + 2 + 2) { throw new IllegalArgumentException("TextBody too short"); + } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + short type = bb.getShort(); short ver = bb.getShort(); - if (type != TYPE || ver != VER) + if (type != TYPE || ver != VER) { throw new IllegalArgumentException("Not TextBody: type=" + type + " ver=" + ver); + } - byte[] payload = new byte[bb.remaining()]; - bb.get(payload); + this.subType = bb.getShort(); + if (this.subType != SUB_NEW && this.subType != SUB_REPLY && this.subType != SUB_REPOST) { + throw new IllegalArgumentException("Bad subType: " + (this.subType & 0xFFFF)); + } + + int textLen = Short.toUnsignedInt(bb.getShort()); + if (textLen <= 0) { + throw new IllegalArgumentException("Text payload is empty"); + } + if (bb.remaining() < textLen) { + throw new IllegalArgumentException("Text payload too short (len=" + textLen + ")"); + } + + byte[] textBytes = new byte[textLen]; + bb.get(textBytes); var decoder = StandardCharsets.UTF_8 .newDecoder() @@ -45,25 +103,122 @@ public final class TextBody implements BodyRecord { .onUnmappableCharacter(CodingErrorAction.REPORT); try { - this.message = decoder.decode(ByteBuffer.wrap(payload)).toString(); + this.message = decoder.decode(ByteBuffer.wrap(textBytes)).toString(); } catch (CharacterCodingException e) { throw new IllegalArgumentException("Text payload is not valid UTF-8", e); } - if (this.message.isBlank()) + if (this.message.isBlank()) { throw new IllegalArgumentException("Text message is blank"); + } + + // Поля ссылки — только для reply/repost + if (this.subType == SUB_REPLY || this.subType == SUB_REPOST) { + + if (bb.remaining() < 1) { + throw new IllegalArgumentException("Missing toBlockchainNameLen"); + } + + int nameLen = Byte.toUnsignedInt(bb.get()); + if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); + if (bb.remaining() < nameLen + 4 + 32) { + throw new IllegalArgumentException("Reply/Repost 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); + + // Запрет мусора в конце + if (bb.remaining() != 0) { + throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); + } + + } else { + // SUB_NEW + this.toBlockchainName = null; + this.toBlockGlobalNumber = 0; + this.toBlockHash32 = null; + + // если кто-то подсунул хвост — лучше упасть, чтобы формат не “плыл” + if (bb.remaining() != 0) { + throw new IllegalArgumentException("Unexpected tail for subType=NEW, remaining=" + bb.remaining()); + } + } } + /* ===================================================================== */ + /* ====================== Конструкторы “для тестов” ====================== */ + /* ===================================================================== */ + + /** + * Удобный конструктор для тестов/сборки простого сообщения: + * new TextBody(text) == new TextBody(SUB_NEW, text) + */ public TextBody(String message) { - Objects.requireNonNull(message, "message == null"); - if (message.isBlank()) - throw new IllegalArgumentException("message is blank"); - this.message = message; + this(SUB_NEW, message); } + /** Сообщение subType=NEW (1). */ + public TextBody(short subType, String message) { + Objects.requireNonNull(message, "message == null"); + + if (subType != SUB_NEW) { + throw new IllegalArgumentException("This constructor is only for SUB_NEW"); + } + if (message.isBlank()) { + throw new IllegalArgumentException("message is blank"); + } + + this.subType = subType; + this.message = message; + + this.toBlockchainName = null; + this.toBlockGlobalNumber = 0; + this.toBlockHash32 = null; + } + + /** Сообщение subType=REPLY (2) или subType=REPOST (3) со ссылкой на блок. */ + public TextBody(short subType, + String message, + String toBlockchainName, + int toBlockGlobalNumber, + byte[] toBlockHash32) { + + Objects.requireNonNull(message, "message == null"); + Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + + if (subType != SUB_REPLY && subType != SUB_REPOST) { + throw new IllegalArgumentException("subType must be SUB_REPLY or SUB_REPOST for this constructor"); + } + if (message.isBlank()) throw new IllegalArgumentException("message is blank"); + if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + + this.subType = subType; + this.message = message; + this.toBlockchainName = toBlockchainName; + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + } + + /* ===================================================================== */ + /* ====================== BodyRecord контракт =========================== */ + /* ===================================================================== */ + @Override public short type() { return TYPE; } @Override public short version() { return VER; } + /** ✅ ВАЖНО: теперь BodyRecord требует subType() */ + @Override public short subType() { return subType; } + @Override public short expectedLineIndex() { return 1; @@ -71,36 +226,137 @@ public final class TextBody implements BodyRecord { @Override public TextBody check() { - if (message == null || message.isBlank()) + if (subType != SUB_NEW && subType != SUB_REPLY && subType != SUB_REPOST) { + throw new IllegalArgumentException("Bad subType: " + (subType & 0xFFFF)); + } + + if (message == null || message.isBlank()) { throw new IllegalArgumentException("Text message is blank"); + } + + if (subType == SUB_REPLY || subType == SUB_REPOST) { + if (toBlockchainName == null || toBlockchainName.isBlank()) + throw new IllegalArgumentException("toBlockchainName is blank"); + if (toBlockGlobalNumber < 0) + throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("toBlockHash32 invalid"); + } else { + // SUB_NEW + if (toBlockchainName != null) throw new IllegalArgumentException("toBlockchainName must be null for SUB_NEW"); + if (toBlockHash32 != null) throw new IllegalArgumentException("toBlockHash32 must be null for SUB_NEW"); + } + return this; } @Override public byte[] toBytes() { - byte[] msg = message.getBytes(StandardCharsets.UTF_8); - if (msg.length == 0) + byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); + if (msgUtf8.length == 0) { throw new IllegalArgumentException("Text payload is empty"); + } + if (msgUtf8.length > 65535) { + throw new IllegalArgumentException("Text too long (>65535 bytes)"); + } + + // base: type+ver + subType + textLen + textBytes + int cap = 4 + 2 + 2 + msgUtf8.length; + + byte[] nameBytes = null; + + if (subType == SUB_REPLY || subType == SUB_REPOST) { + nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (nameBytes.length == 0 || nameBytes.length > 255) { + throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); + } + if (toBlockHash32 == null || toBlockHash32.length != 32) { + throw new IllegalArgumentException("toBlockHash32 != 32"); + } + + cap += 1 + nameBytes.length + 4 + 32; + + } else { + // SUB_NEW — ссылка запрещена + if (toBlockchainName != null || toBlockHash32 != null) { + throw new IllegalArgumentException("SUB_NEW must not contain reply/repost fields"); + } + } + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - ByteBuffer bb = ByteBuffer.allocate(4 + msg.length).order(ByteOrder.BIG_ENDIAN); bb.putShort(TYPE); bb.putShort(VER); - bb.put(msg); + + bb.putShort(subType); + + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + + if (subType == SUB_REPLY || subType == SUB_REPOST) { + bb.put((byte) nameBytes.length); + bb.put(nameBytes); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + } + return bb.array(); } @Override public String toString() { + String st = switch (subType) { + case SUB_NEW -> "NEW (1)"; + case SUB_REPLY -> "REPLY (2)"; + case SUB_REPOST -> "REPOST (3)"; + default -> "UNKNOWN"; + }; + + if (subType == SUB_REPLY || subType == SUB_REPOST) { + return """ + TextBody { + тип записи : TEXT (type=1, ver=1) + ожидаемая линия : 1 + subType : %s + длина сообщения : %d байт + текст сообщения : "%s" + ссылка на блок : "%s" #%d + hash цели (hex) : %s + } + """.formatted( + st, + message.getBytes(StandardCharsets.UTF_8).length, + message, + toBlockchainName, + toBlockGlobalNumber, + toBlockHashHex() + ); + } + return """ TextBody { тип записи : TEXT (type=1, ver=1) ожидаемая линия : 1 + subType : %s длина сообщения : %d байт - текст сообщения : "%s" + текст сообщения : "%s" } """.formatted( - message.getBytes(StandardCharsets.UTF_8).length, - message - ); + st, + message.getBytes(StandardCharsets.UTF_8).length, + message + ); + } + + public String toBlockHashHex() { + if (toBlockHash32 == null) return "null"; + char[] HEX = "0123456789abcdef".toCharArray(); + char[] out = new char[64]; + for (int i = 0; i < 32; i++) { + int v = toBlockHash32[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/src/test/java/test/it/IT_03_AddBlock_NoAuth.java b/src/test/java/test/it/IT_03_AddBlock_NoAuth.java index 991ea21..9e57686 100644 --- a/src/test/java/test/it/IT_03_AddBlock_NoAuth.java +++ b/src/test/java/test/it/IT_03_AddBlock_NoAuth.java @@ -107,7 +107,7 @@ public class IT_03_AddBlock_NoAuth { // ========================================================= if (TestConfig.DEBUG()) TestLog.stepTitle("ШАГ 4: AddBlock REACT#1 (line=2) -> на TEXT#1 (global=1)"); flow.sendNextReaction( - 1, // reactionCode (пример: 1 = like) + (short) 1, // reactionCode (пример: 1 = like) TestConfig.BCH_NAME(), // toBlockchainName 1, // toBlockGlobalNumber = 1 (TEXT#1) text1.hash32, // toBlockHash32 = hash(TEXT#1) diff --git a/src/test/java/test/it/addBlockUtils/AddBlockFlow.java b/src/test/java/test/it/addBlockUtils/AddBlockFlow.java index b4bc3d6..e4c6046 100644 --- a/src/test/java/test/it/addBlockUtils/AddBlockFlow.java +++ b/src/test/java/test/it/addBlockUtils/AddBlockFlow.java @@ -160,7 +160,7 @@ public final class AddBlockFlow { } /** Шлём следующий REACTION блок в line=2, ссылаясь на конкретный блок. */ - public BuiltBlock sendNextReaction(int reactionCode, + public BuiltBlock sendNextReaction(short reactionCode, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, @@ -305,7 +305,7 @@ public final class AddBlockFlow { int lineBlockNumber, byte[] prevGlobalHash32, byte[] prevLineHash32, - int reactionCode, + short reactionCode, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) {