Переписал код кучи классов перешёл на новый надеюсь теперь подходящий формат блоков

и тесты переделал.

Но пока остались баги и тесты не проходят (в частности пользователи не создаются - ошибка в бд)
This commit is contained in:
AidarKC 2026-01-13 16:18:38 +03:00
parent b7025dde59
commit e9e05c1192
23 changed files with 1516 additions and 2379 deletions

View File

@ -1,3 +1,6 @@
// =======================
// blockchain/BchBlockEntry.java (НОВАЯ ВЕРСИЯ под ТЗ)
// =======================
package blockchain;
import blockchain.body.BodyRecord;
@ -9,100 +12,66 @@ import java.time.Instant;
import java.util.Arrays;
import java.util.Objects;
/**
* старый формат -его надо поменять на новый формат
*
* RAW (BigEndian):
* [4] recordSize (int) = размер RAW (включая этот заголовок), БЕЗ signature+hash
* [4] recordNumber (int) глобальный номер блока
* [8] timestamp (long) unix seconds
[2] lineIndex (short)
* [4] lineNumber (int)
* [N] bodyBytes (body, начинается с [type][version])
*
* TAIL (НЕ входит в recordSize):
* [64] signature64 (Ed25519)
* [32] hash32 (SHA-256)
*/
/**
* BchBlockEntry универсальный блок нового формата.
*
* RAW (BigEndian):
* Неизменное заглавие
* [32] prevHash32 (SHA-256) ХЭЩ ПРИВЕДУЩЕГО
* [4] blockSize (int) = размер RAW (включая этот заголовок), БЕЗ signature
* [4] blockNumber (int) номер блока
* RAW (BigEndian) = preimage:
* [32] prevHash32 (SHA-256) hash предыдущего блока (цепочка)
* [4] blockSize (int) = размер preimage (в байтах), БЕЗ signature64
* [4] blockNumber (int) глобальный номер блока
* [8] timestamp (long) unix seconds
*
* [2] type - тип соощения
* [2] Sиbtype - субтип сообщения
* [2] version - версия формата соощения
* [2] type (short) тип сообщения
* [2] subType (short) подтип сообщения
* [2] version (short) версия формата сообщения
*
* [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version)
*
* Дальше Само сообщение (может быть разным)
* [4] prevLineNumber НОМЕР ПРИВЕДУЩЕГО СООБЩЕНИЯ В ЛИНИИ - может быть а может и небыть в зависимости от типа сообщения
* [32] prevLineHash ХЭШ ПРИВЕДУЩЕГО СООБЩЕНИЯ В ЛИНИИ - может быть а может и небыть в зависимости от типа сообщения
* [4] номер самого сообщения в этой линии
* [N] bodyBytes (ОСТАЛЬНЫЕ БАЙТЫ])
* TAIL (НЕ входит в recordSize):
* [64] signature64 (Ed25519)
* И хэш в конце блока мы не храним, тк он будет в начале следующего блока. А для проверки блока оно не нужно тк мы каждый раз провеяем подпись . А она основана на хэше
* TAIL (НЕ входит в blockSize):
* [64] signature64 (Ed25519) подпись над hash32
*
* [32] hash32 (SHA-256)
* hash32 ВНУТРИ БЛОКА НЕ ХРАНИМ.
* hash32 вычисляется при парсинге:
* preimage = первые blockSize байт
* hash32 = SHA-256(preimage)
*/
public final class BchBlockEntry {
public static final int SIGNATURE_LEN = 64;
public static final int HASH_LEN = 32;
/** Размер фиксированного RAW-заголовка без body */
public static final int RAW_HEADER_SIZE = 4 + 4 + 8 + 2 + 4;
public static final int RAW_HEADER_SIZE =
32 // prevHash32
+ 4 // blockSize
+ 4 // blockNumber
+ 8 // timestamp
+ 2 // type
+ 2 // subType
+ 2; // version
// --- RAW ---
public final int recordSize; // только RAW, без signature+hash
public final int recordNumber;
// --- HEADER (RAW) ---
public final byte[] prevHash32; // 32
public final int blockSize; // preimage size
public final int blockNumber;
public final long timestamp;
public final short lineIndex;
public final int lineNumber;
public final short type;
public final short subType;
public final short version;
// --- BODY (RAW) ---
public final byte[] bodyBytes;
/** Распарсенное тело (создаётся сразу при парсинге блока). */
public final BodyRecord body;
// --- TAIL ---
private final byte[] signature64;
private final byte[] hash32;
private final byte[] signature64; // 64
// --- cached ---
private final byte[] fullBytes;
// --- derived ---
private final byte[] hash32; // 32, computed
private final byte[] preimage; // blockSize bytes
private final byte[] fullBytes; // preimage + signature
/* ===================================================================== */
/* ====================== Конструктор из байт ========================== */
@ -110,113 +79,113 @@ public final class BchBlockEntry {
public BchBlockEntry(byte[] fullBytes) {
Objects.requireNonNull(fullBytes, "fullBytes == null");
if (fullBytes.length < RAW_HEADER_SIZE + SIGNATURE_LEN + HASH_LEN)
if (fullBytes.length < RAW_HEADER_SIZE + SIGNATURE_LEN) {
throw new IllegalArgumentException("Block too short");
}
ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN);
this.recordSize = bb.getInt();
if (recordSize + SIGNATURE_LEN + HASH_LEN != fullBytes.length)
throw new IllegalArgumentException("recordSize mismatch");
this.prevHash32 = new byte[32];
bb.get(this.prevHash32);
this.recordNumber = bb.getInt();
this.blockSize = bb.getInt();
if (blockSize < RAW_HEADER_SIZE) {
throw new IllegalArgumentException("blockSize too small: " + blockSize);
}
if (blockSize + SIGNATURE_LEN != fullBytes.length) {
throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize + " fullLen=" + fullBytes.length);
}
this.blockNumber = bb.getInt();
this.timestamp = bb.getLong();
this.lineIndex = bb.getShort();
this.lineNumber = bb.getInt();
int bodyLen = recordSize - RAW_HEADER_SIZE;
if (bodyLen <= 0)
throw new IllegalArgumentException("Invalid body length");
this.type = bb.getShort();
this.subType = bb.getShort();
this.version = bb.getShort();
int bodyLen = blockSize - RAW_HEADER_SIZE;
if (bodyLen < 0) throw new IllegalArgumentException("Invalid body length: " + bodyLen);
this.bodyBytes = new byte[bodyLen];
bb.get(this.bodyBytes);
// Сразу парсим 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);
this.hash32 = new byte[HASH_LEN];
bb.get(this.hash32);
// preimage = первые blockSize байт
this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize);
// hash32 = sha256(preimage)
this.hash32 = BchCryptoVerifier.sha256(preimage);
// parse body по header.type/subType/version
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(int recordNumber,
public BchBlockEntry(byte[] prevHash32,
int blockNumber,
long timestamp,
short lineIndex,
int lineNumber,
short type,
short subType,
short version,
byte[] bodyBytes,
byte[] signature64,
byte[] hash32) {
byte[] signature64) {
Objects.requireNonNull(prevHash32, "prevHash32 == null");
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
Objects.requireNonNull(signature64, "signature64 == null");
Objects.requireNonNull(hash32, "hash32 == null");
if (signature64.length != SIGNATURE_LEN)
throw new IllegalArgumentException("signature64 != 64");
if (hash32.length != HASH_LEN)
throw new IllegalArgumentException("hash32 != 32");
if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32");
if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64");
this.recordNumber = recordNumber;
this.prevHash32 = Arrays.copyOf(prevHash32, 32);
this.blockNumber = blockNumber;
this.timestamp = timestamp;
this.lineIndex = lineIndex;
this.lineNumber = lineNumber;
this.type = type;
this.subType = subType;
this.version = version;
this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length);
// И при сборке тоже сразу парсим 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);
// recordSize теперь только RAW (header + body), без signature+hash
this.recordSize = RAW_HEADER_SIZE + bodyBytes.length;
this.blockSize = RAW_HEADER_SIZE + this.bodyBytes.length;
int fullLen = this.recordSize + SIGNATURE_LEN + HASH_LEN;
// parse body по header
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
ByteBuffer bb = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN);
bb.putInt(this.recordSize);
bb.putInt(recordNumber);
bb.putLong(timestamp);
bb.putShort(lineIndex);
bb.putInt(lineNumber);
bb.put(bodyBytes);
bb.put(this.signature64);
bb.put(this.hash32);
// build preimage
ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN);
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.fullBytes = bb.array();
this.preimage = pre.array();
this.hash32 = BchCryptoVerifier.sha256(preimage);
ByteBuffer full = ByteBuffer.allocate(blockSize + SIGNATURE_LEN).order(ByteOrder.BIG_ENDIAN);
full.put(this.preimage);
full.put(this.signature64);
this.fullBytes = full.array();
}
public byte[] getRawBytes() {
int rawLen = recordSize; // ровно RAW, без signature+hash
byte[] raw = new byte[rawLen];
System.arraycopy(fullBytes, 0, raw, 0, rawLen);
return raw;
public byte[] getPreimageBytes() {
return Arrays.copyOf(preimage, preimage.length);
}
public byte[] getSignature64() {
@ -241,26 +210,18 @@ public final class BchBlockEntry {
}
return "BchBlockEntry{"
+ "RAW{"
+ "recordSize=" + recordSize
+ ", recordNumber=" + recordNumber
+ "HDR{"
+ "blockSize=" + blockSize
+ ", blockNumber=" + blockNumber
+ ", timestamp=" + timestamp + " (" + timeIso + ")"
+ ", lineIndex=" + lineIndex
+ ", lineNumber=" + lineNumber
+ ", bodyLen=" + (bodyBytes == null ? -1 : bodyBytes.length)
+ ", bodyType=" + (body == null ? "?" : (body.type() & 0xFFFF))
+ ", bodyVer=" + (body == null ? "?" : (body.version() & 0xFFFF))
+ ", type=" + (type & 0xFFFF)
+ ", subType=" + (subType & 0xFFFF)
+ ", version=" + (version & 0xFFFF)
+ ", prevHash32(hex)=" + toHex(prevHash32)
+ "}"
+ ", TAIL{"
+ "signature64(hex)=" + toHex(signature64)
+ ", hash32(hex)=" + toHex(hash32)
+ "}"
+ ", FULL{"
+ "fullLen=" + (fullBytes == null ? -1 : fullBytes.length)
+ ", rawLen=" + recordSize
+ "}"
+ ", body=" + (body == null ? "null" : body.toString())
+ ", bodyBytesPreview(hex32)=" + toHexPreview(bodyBytes, 32)
+ ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}"
+ ", TAIL{signature64(hex)=" + toHex(signature64) + "}"
+ ", DERIVED{hash32(hex)=" + toHex(hash32) + "}"
+ "}";
}
@ -275,14 +236,4 @@ public final class BchBlockEntry {
}
return new String(out);
}
private static String toHexPreview(byte[] bytes, int maxBytes) {
if (bytes == null) return "null";
if (maxBytes <= 0) return "";
int n = Math.min(bytes.length, maxBytes);
byte[] cut = Arrays.copyOf(bytes, n);
String hex = toHex(cut);
if (bytes.length > n) hex += "…(+" + (bytes.length - n) + " байт)";
return hex;
}
}

View File

@ -1,66 +1,26 @@
// =======================
// blockchain/BchCryptoVerifier.java (НОВАЯ ВЕРСИЯ под ТЗ)
// =======================
package blockchain;
import utils.config.ShineSignatureConstants;
import utils.crypto.Ed25519Util;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Objects;
/**
* Новый верификатор по ТЗ:
*
* preimage = все байты блока без signature64
* hash32 = SHA-256(preimage)
* verify = Ed25519.verify(hash32, signature64, pubKey32)
*/
public final class BchCryptoVerifier {
private BchCryptoVerifier() {}
// строка из констант; байты/длина локально, на месте
private static final String DOMAIN_STR = ShineSignatureConstants.BLOCK_HASH_DOMAIN;
private static final byte[] DOMAIN = DOMAIN_STR.getBytes(StandardCharsets.US_ASCII);
private static final int DOMAIN_LEN = DOMAIN.length;
/**
* preimage =
* DOMAIN +
* [1] loginLen + loginBytes +
* prevGlobalHash32 +
* prevLineHash32 +
* rawBytes
*/
public static byte[] buildPreimage(String userLogin,
byte[] prevGlobalHash32,
byte[] prevLineHash32,
byte[] rawBytes) {
Objects.requireNonNull(userLogin, "userLogin == null");
Objects.requireNonNull(prevGlobalHash32, "prevGlobalHash32 == null");
Objects.requireNonNull(prevLineHash32, "prevLineHash32 == null");
Objects.requireNonNull(rawBytes, "rawBytes == null");
if (prevGlobalHash32.length != 32 || prevLineHash32.length != 32)
throw new IllegalArgumentException("hash len != 32");
byte[] loginBytes = userLogin.getBytes(StandardCharsets.UTF_8);
if (loginBytes.length > 255)
throw new IllegalArgumentException("login >255 bytes");
ByteBuffer bb = ByteBuffer.allocate(
DOMAIN_LEN +
1 + loginBytes.length +
32 + 32 +
rawBytes.length
).order(ByteOrder.BIG_ENDIAN);
bb.put(DOMAIN);
bb.put((byte) loginBytes.length);
bb.put(loginBytes);
bb.put(prevGlobalHash32);
bb.put(prevLineHash32);
bb.put(rawBytes);
return bb.array();
}
public static byte[] sha256(byte[] data) {
Objects.requireNonNull(data, "data == null");
try {
MessageDigest d = MessageDigest.getInstance("SHA-256");
return d.digest(data);
@ -69,34 +29,15 @@ public final class BchCryptoVerifier {
}
}
/**
* Проверка подписи Ed25519:
*/
public static boolean verifyAll(String userLogin,
byte[] prevGlobalHash32,
byte[] prevLineHash32,
byte[] rawBytes,
byte[] signature64,
byte[] publicKey32,
byte[] expectedHash32FromBlock) {
Objects.requireNonNull(signature64, "signature64 == null");
public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) {
Objects.requireNonNull(block, "block == null");
Objects.requireNonNull(publicKey32, "publicKey32 == null");
Objects.requireNonNull(expectedHash32FromBlock, "expectedHash32FromBlock == null");
if (signature64.length != 64) throw new IllegalArgumentException("signature64 != 64");
if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32");
if (expectedHash32FromBlock.length != 32) throw new IllegalArgumentException("hash32 != 32");
byte[] preimage = buildPreimage(userLogin, prevGlobalHash32, prevLineHash32, rawBytes);
byte[] hash32 = sha256(preimage);
byte[] hash32 = block.getHash32();
byte[] sig64 = block.getSignature64();
// 1) сверяем hash, который лежит в блоке
if (!java.util.Arrays.equals(hash32, expectedHash32FromBlock)) {
return false;
}
// 2) проверяем подпись (Ed25519 над hash32)
return Ed25519Util.verify(hash32, signature64, publicKey32);
return Ed25519Util.verify(hash32, sig64, publicKey32);
}
}

View File

@ -1,7 +1,10 @@
// =======================
// blockchain/body/BodyHasTarget.java (без изменений, оставляю как есть)
// =======================
package blockchain.body;
/**
* BodyToFields дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
* BodyHasTarget дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
*
* Идея:
* - Не все body имеют "to".
@ -10,11 +13,6 @@ package blockchain.body;
*
* Важно:
* - Все методы могут возвращать null.
* - toLogin может отсутствовать в самом формате body (например, ReactionBody, TextBody reply/repost),
* но в БД мы пишем toLogin "про запас".
* Поэтому writer может:
* - взять toLogin из body (если есть),
* - либо попытаться вычислить из toBchName.
*/
public interface BodyHasTarget {

View File

@ -1,56 +1,29 @@
// =======================
// blockchain/body/BodyRecord.java (ИЗМЕНЁННЫЙ контракт под ТЗ)
// =======================
package blockchain.body;
/**
* BodyRecord_new общий контракт для всех типов body (тела блока).
* BodyRecord общий контракт для всех типов body (тела блока).
*
* Идея:
* - На каждый тип body (Header, Text, Reaction, ...) отдельный класс.
* - Десериализация из байтов делается КОНСТРУКТОРОМ:
* new XxxBody_new(byte[] bodyBytes)
* (конструктор обязан распарсить байты или кинуть IllegalArgumentException).
* ВАЖНО (новый формат):
* - type/subType/version НЕ лежат в bodyBytes.
* - type/subType/version читаются из заголовка блока (BchBlockEntry).
*
* - Валидация делается методом check().
* check() должен:
* - вернуть this, если всё корректно
* - кинуть IllegalArgumentException, если данные некорректны
*
* - Сериализация обратно в байты делается методом toBytes().
*
* - type() и version() это идентификаторы формата body.
* Они должны быть константами для класса (например TYPE=1, VERSION=1).
*
* ДОПОЛНЕНИЕ (ЛИНИИ):
* - Каждый тип body знает, в какой lineIndex он ДОЛЖЕН находиться.
* Это проверяется в валидаторе блока (уровень B).
*
* ДОПОЛНЕНИЕ (SUBTYPE):
* - У каждого body есть subType (uint16).
* - Для HeaderBody он всегда 0 (служебная совместимость).
* - Для TextBody это тип сообщения (NEW/REPLY/REPOST).
* - Для ReactionBody это тип реакции (LIKE и т.п.).
* Поэтому из интерфейса УБРАНЫ:
* - type()
* - subType()
* - version()
* - expectedLineIndex()
*/
public interface BodyRecord {
/** Код типа записи (совпадает с type в bodyBytes). */
short type();
/** Версия формата записи (совпадает с version в bodyBytes). */
short version();
/**
* Подтип записи (uint16).
*/
short subType();
/** Ожидаемый индекс линии для этого body. */
short expectedLineIndex();
/** Проверить корректность содержимого и вернуть этот объект (или кинуть исключение). */
BodyRecord check();
/**
* Сериализовать тело записи в байты (ровно то, что кладётся в block.body).
* Важно: включает type/version/subType и весь payload.
* Сериализовать тело записи в байты (ровно то, что кладётся в block.bodyBytes).
* Важно: НЕ включает type/subType/version.
*/
byte[] toBytes();
}

View File

@ -1,31 +1,34 @@
// =======================
// blockchain/body/BodyRecordParser.java (ИЗМЕНЁННЫЙ под новый формат)
// =======================
package blockchain.body;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
/**
* Парсер body теперь выбирает класс по header: type/subType/version,
* потому что bodyBytes больше НЕ содержат type/subType/version.
*/
public final class BodyRecordParser {
private BodyRecordParser() {}
public static BodyRecord parse(byte[] bodyBytes) {
public static BodyRecord parse(short type, short subType, short version, byte[] bodyBytes) {
if (bodyBytes == null) throw new IllegalArgumentException("bodyBytes == null");
if (bodyBytes.length < 4) throw new IllegalArgumentException("bodyBytes too short (<4)");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
short type = bb.getShort();
short ver = bb.getShort();
int t = type & 0xFFFF;
int v = version & 0xFFFF;
int key = ((type & 0xFFFF) << 16) | (ver & 0xFFFF);
// ключ = (type<<16)|version (как раньше по смыслу), но берём из HEADER
int key = (t << 16) | v;
return switch (key) {
case HeaderBody.KEY -> new HeaderBody(bodyBytes); // type=0, ver=1 заглавие блокчейна
case TextBody.KEY -> new TextBody(bodyBytes); // type=1, ver=1 текст
case ReactionBody.KEY -> new ReactionBody(bodyBytes); // type=2, ver=1 реакции
case ConnectionBody.KEY -> new ConnectionBody(bodyBytes); // type=3, ver=1 связи
case UserParamBody.KEY -> new UserParamBody(bodyBytes); // type=4, ver=1 параметры пользователя
case HeaderBody.KEY -> new HeaderBody(subType, version, bodyBytes);
case TextBody.KEY -> new TextBody(subType, version, bodyBytes);
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
default -> throw new IllegalArgumentException(String.format(
"Unknown body type/version: type=%d ver=%d (key=0x%08X)",
(type & 0xFFFF), (ver & 0xFFFF), key
"Unknown body type/version from header: type=%d ver=%d subType=%d",
t, v, (subType & 0xFFFF)
));
};
}

View File

@ -1,6 +1,9 @@
// =======================
// blockchain/body/ConnectionBody.java (ИЗМЕНЁННЫЙ: bodyBytes без type/subType/version, + line fields)
// =======================
package blockchain.body;
import blockchain.LineIndex;
import shine.db.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
@ -9,112 +12,75 @@ import java.util.Arrays;
import java.util.Objects;
/**
* ConnectionBody type=3, ver=1. (Связь/отношение)
* ConnectionBody type=3, ver=1 (в заголовке блока).
*
* Идея:
* - Это запись "у меня есть связь с X" ИЛИ "я отменяю связь с X".
* - subType определяет вид связи и действие.
* subType (в заголовке блока) как MsgSubType:
* FRIEND=10, UNFRIEND=11
* CONTACT=20, UNCONTACT=21
* FOLLOW=30, UNFOLLOW=31
*
* subType (uint16):
* УСТАНОВИТЬ связь:
* 10 = FRIEND (друг)
* 20 = CONTACT (контакт)
* 30 = FOLLOW (подписан на кого-то)
*
* ОТМЕНИТЬ связь (событие, которое снимает прошлую связь):
* 11 = UNFRIEND (больше не друг)
* 21 = UNCONTACT (больше не контакт)
* 31 = UNFOLLOW (больше не подписан)
*
* Важно про смысл:
* - Состояние связи вычисляется по последнему блоку данной категории:
* (toLogin, kind=FRIEND/CONTACT/FOLLOW)
* Если последний subType 10/20/30 => связь активна
* Если последний subType 11/21/31 => связь снята
*
* Формат bodyBytes (BigEndian):
* [2] type=3
* [2] ver=1
*
* [2] subType (uint16) вид связи (10,20/30)
* bodyBytes (BigEndian), новый формат:
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
*
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* ВАЖНО: toLogin это "с кем связь" (ключевой смысл этой записи).
*
* [1] toBlockchainNameLen (uint8)
* [M] toBlockchainName UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes)
*
* ВАЖНО: поля toBlockchainName/toBlockGlobalNumber/toBlockHash32 это
* "последний известный блок" того человека (снимок/якорь состояния).
*
* ЛИНИЯ:
* - строго lineIndex=3 (выделяем отдельную линию под связи).
*/
public final class ConnectionBody implements BodyRecord, BodyHasTarget {
public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
public static final short TYPE = 3;
public static final short VER = 1;
/** Удобный ключ для BodyRecordParser: (type<<16)|ver */
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
// --- subType: SET ---
public static final short SUB_FRIEND = 10;
public static final short SUB_CONTACT = 20;
public static final short SUB_FOLLOW = 30;
public final short subType; // из header
public final short version; // из header
// --- subType: UNSET (снятие/отмена связи) ---
public static final short SUB_UNFRIEND = 11; // больше не друг
public static final short SUB_UNCONTACT = 21; // больше не контакт
public static final short SUB_UNFOLLOW = 31; // больше не подписан
// line
public final int prevLineNumber;
public final byte[] prevLineHash32;
public final int thisLineNumber;
public final short subType;
/** С кем связь (главное поле). */
// payload
public final String toLogin;
/** Блокчейн того человека (снимок/якорь). */
public final String toBlockchainName;
/** Номер последнего известного блока у того человека (снимок/якорь). */
public final int toBlockGlobalNumber;
/** Хэш последнего известного блока у того человека (снимок/якорь). */
public final byte[] toBlockHash32;
/* ===================================================================== */
/* ====================== Конструктор из байт =========================== */
/* ===================================================================== */
public ConnectionBody(byte[] bodyBytes) {
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ConnectionBody version must be 1, got=" + (this.version & 0xFFFF));
}
if (!isValidSubType(this.subType)) {
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
}
// минимум:
// type[2]+ver[2]+subType[2] +
// toLoginLen[1]+toLogin[1] +
// toBchLen[1]+toBch[1] +
// global[4] + hash[32]
if (bodyBytes.length < 2 + 2 + 2 + 1 + 1 + 1 + 1 + 4 + 32) {
// line(4+32+4) + toLoginLen[1]+toLogin[1] + toBchLen[1]+toBch[1] + global[4] + hash[32]
if (bodyBytes.length < (4 + 32 + 4) + 1 + 1 + 1 + 1 + 4 + 32) {
throw new IllegalArgumentException("ConnectionBody 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 ConnectionBody: type=" + type + " ver=" + ver);
}
this.prevLineNumber = bb.getInt();
this.subType = bb.getShort();
if (!isValidSubType(this.subType)) {
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
}
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
// --- toLogin ---
int toLoginLen = Byte.toUnsignedInt(bb.get());
if (toLoginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
if (bb.remaining() < toLoginLen) throw new IllegalArgumentException("toLogin payload too short");
@ -123,8 +89,6 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget {
bb.get(toLoginBytes);
this.toLogin = new String(toLoginBytes, StandardCharsets.UTF_8);
// --- toBlockchainName + snapshot блока ---
if (bb.remaining() < 1) throw new IllegalArgumentException("Missing toBlockchainNameLen");
int bchLen = Byte.toUnsignedInt(bb.get());
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
@ -138,17 +102,13 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget {
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
// запрет мусора в конце
if (bb.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
/* ===================================================================== */
/* ====================== Конструктор “вручную” ========================= */
/* ===================================================================== */
public ConnectionBody(short subType,
public ConnectionBody(int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
short subType,
String toLogin,
String toBlockchainName,
int toBlockGlobalNumber,
@ -158,19 +118,21 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (!isValidSubType(subType)) {
throw new IllegalArgumentException("Unknown connection subType: " + (subType & 0xFFFF));
}
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (!toLogin.matches("^[A-Za-z0-9_]+$"))
throw new IllegalArgumentException("toLogin must match ^[A-Za-z0-9_]+$");
if (!toLogin.matches("^[A-Za-z0-9_]+$")) throw new IllegalArgumentException("toLogin must match ^[A-Za-z0-9_]+$");
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.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
this.thisLineNumber = thisLineNumber;
this.subType = subType;
this.version = VER;
this.toLogin = toLogin;
this.toBlockchainName = toBlockchainName;
this.toBlockGlobalNumber = toBlockGlobalNumber;
@ -178,62 +140,33 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget {
}
private static boolean isValidSubType(short st) {
return st == SUB_FRIEND || st == SUB_CONTACT || st == SUB_FOLLOW
|| st == SUB_UNFRIEND || st == SUB_UNCONTACT || st == SUB_UNFOLLOW;
}
/** true если это событие установки связи (10/20/30). */
public boolean isSetAction() {
return subType == SUB_FRIEND || subType == SUB_CONTACT || subType == SUB_FOLLOW;
}
/** true если это событие снятия связи (11/21/31). */
public boolean isUnsetAction() {
return subType == SUB_UNFRIEND || subType == SUB_UNCONTACT || subType == SUB_UNFOLLOW;
}
/**
* Нормализованный вид связи без действия:
* FRIEND / CONTACT / FOLLOW
*/
public short kind() {
return switch (subType) {
case SUB_FRIEND, SUB_UNFRIEND -> SUB_FRIEND;
case SUB_CONTACT, SUB_UNCONTACT -> SUB_CONTACT;
case SUB_FOLLOW, SUB_UNFOLLOW -> SUB_FOLLOW;
default -> throw new IllegalStateException("Unexpected subType: " + (subType & 0xFFFF));
};
}
/* ===================================================================== */
/* ====================== BodyRecord контракт =========================== */
/* ===================================================================== */
@Override public short type() { return TYPE; }
@Override public short version() { return VER; }
@Override public short subType() { return subType; }
@Override
public short expectedLineIndex() {
return LineIndex.CONNECTION;
int v = st & 0xFFFF;
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF);
}
@Override
public ConnectionBody check() {
if (!isValidSubType(subType))
throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("toLogin is blank");
if (!toLogin.matches("^[A-Za-z0-9_]+$"))
throw new IllegalArgumentException("toLogin must match ^[A-Za-z0-9_]+$");
// line rule
if (prevLineNumber == -1) {
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
} else {
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
}
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");
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (!toLogin.matches("^[A-Za-z0-9_]+$")) throw new IllegalArgumentException("toLogin must match ^[A-Za-z0-9_]+$");
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;
}
@ -248,26 +181,19 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget {
if (bchBytes.length == 0 || bchBytes.length > 255)
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
if (!isValidSubType(subType))
throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 != 32");
// type[2]+ver[2]+subType[2]
// + toLoginLen[1]+toLogin[N]
// + toBchLen[1]+toBch[M]
// + global[4]+hash[32]
int cap = 2 + 2 + 2
int cap = (4 + 32 + 4)
+ 1 + toLoginBytes.length
+ 1 + bchBytes.length
+ 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putShort(TYPE);
bb.putShort(VER);
bb.putShort(subType);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.put((byte) toLoginBytes.length);
bb.put(toLoginBytes);
@ -281,69 +207,20 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget {
return bb.array();
}
@Override
public String toString() {
String st = switch (subType) {
case SUB_FRIEND -> "FRIEND (10)";
case SUB_CONTACT -> "CONTACT (20)";
case SUB_FOLLOW -> "FOLLOW (30)";
case SUB_UNFRIEND -> "UNFRIEND (11)";
case SUB_UNCONTACT -> "UNCONTACT (21)";
case SUB_UNFOLLOW -> "UNFOLLOW (31)";
default -> "UNKNOWN";
};
String action = isSetAction() ? "SET" : (isUnsetAction() ? "UNSET" : "?");
String kindStr = switch (kind()) {
case SUB_FRIEND -> "FRIEND";
case SUB_CONTACT -> "CONTACT";
case SUB_FOLLOW -> "FOLLOW";
default -> "?";
};
return """
ConnectionBody {
тип записи : CONNECTION (type=3, ver=1)
ожидаемая линия : 3
subType : %s
действие : %s
вид связи : %s
связь с login : "%s"
блокчейн друга/цели : "%s"
lastKnown globalNumber : %d
lastKnown hash (hex) : %s
}
""".formatted(
st,
action,
kindStr,
toLogin,
toBlockchainName,
toBlockGlobalNumber,
toBlockHashHex()
);
private static boolean isAllZero32(byte[] b) {
if (b == null || b.length != 32) return true;
for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
return true;
}
public String toBlockHashHex() {
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);
}
/* ===================================================================== */
/* ====================== BodyHasTarget контракт ========================= */
/* ===================================================================== */
/* ====================== BodyHasLine ====================== */
@Override public int prevLineNumber() { return prevLineNumber; }
@Override public byte[] prevLineHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
@Override public int thisLineNumber() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */
@Override public String toLogin() { return toLogin; }
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHasheBytes() { return toBlockHash32; }
}

View File

@ -1,6 +1,8 @@
// =======================
// blockchain/body/HeaderBody.java (ИЗМЕНЁННЫЙ: bodyBytes без type/subType/version)
// =======================
package blockchain.body;
import blockchain.LineIndex;
import utils.config.ShineSignatureConstants;
import java.nio.ByteBuffer;
@ -11,18 +13,13 @@ import java.util.Objects;
/**
* HeaderBody type=0, version=1.
*
* Полный bodyBytes (BigEndian):
* [2] type=0
* [2] version=1
*
* [2] subType (uint16) = 0
* В новом формате type/subType/version живут в HEADER блока,
* поэтому bodyBytes для HeaderBody содержат только payload:
*
* bodyBytes (BigEndian):
* [TAG_LEN] tag ASCII "SHiNE"
* [1] loginLength=N (uint8)
* [N] login UTF-8
*
* ЛИНИЯ:
* - строго lineIndex=0 (genesis)
*/
public final class HeaderBody implements BodyRecord {
@ -31,40 +28,39 @@ public final class HeaderBody implements BodyRecord {
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
/** Для header всегда 0 (служебная совместимость). */
/** Для header subType всегда 0 (служебная совместимость). */
public static final short SUBTYPE_COMPAT = 0;
/** TAG формата (ASCII). Значение берём из общих строковых констант. */
/** TAG формата (ASCII). */
public static final String TAG = ShineSignatureConstants.BLOCKCHAIN_HEADER_TAG;
// производные значения считаем "на месте", а не в константах
private static final byte[] TAG_ASCII = TAG.getBytes(StandardCharsets.US_ASCII);
private static final int TAG_LEN = TAG_ASCII.length;
public final short subType; // всегда 0
public final short subType; // всегда 0 (из заголовка блока)
public final short version; // из заголовка блока
public final String tag; // "SHiNE"
public final String login;
/** Десериализация из полного bodyBytes (включая type/version/subType). */
public HeaderBody(byte[] bodyBytes) {
/** Десериализация из payload bodyBytes (без type/subType/version). */
public HeaderBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
if (bodyBytes.length < 4 + 2) throw new IllegalArgumentException("HeaderBody too short (<6)");
this.subType = subType;
this.version = version;
if ((this.subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF)) {
throw new IllegalArgumentException("HeaderBody subType must be 0, got=" + (this.subType & 0xFFFF));
}
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("HeaderBody version must be 1, got=" + (this.version & 0xFFFF));
}
// минимум: tag[TAG_LEN] + loginLen[1]
if (bodyBytes.length < TAG_LEN + 1) throw new IllegalArgumentException("HeaderBody 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 HeaderBody: type=" + type + " ver=" + ver);
this.subType = bb.getShort();
if (this.subType != SUBTYPE_COMPAT)
throw new IllegalArgumentException("HeaderBody subType must be 0, got=" + (this.subType & 0xFFFF));
// дальше: tag[TAG_LEN] + loginLen[1] минимум
if (bb.remaining() < TAG_LEN + 1)
throw new IllegalArgumentException("Header payload too short");
byte[] tagBytes = new byte[TAG_LEN];
bb.get(tagBytes);
String t = new String(tagBytes, StandardCharsets.US_ASCII);
@ -79,59 +75,43 @@ public final class HeaderBody implements BodyRecord {
bb.get(loginBytes);
this.login = new String(loginBytes, StandardCharsets.UTF_8);
if (bb.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
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.version = VER;
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() {
return LineIndex.HEADER;
}
@Override
public HeaderBody check() {
if (subType != SUBTYPE_COMPAT)
if ((subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF))
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_]+$"))
throw new IllegalArgumentException("Login must match ^[A-Za-z0-9_]+$");
return this;
}
@Override
public byte[] toBytes() {
byte[] loginUtf8 = login.getBytes(StandardCharsets.UTF_8);
if (loginUtf8.length > 255)
throw new IllegalArgumentException("Login too long (>255 bytes)");
if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("Login utf8 len must be 1..255");
// type[2] + ver[2] + subType[2] + tag[TAG_LEN] + loginLen[1] + login[N]
int cap = 2 + 2 + 2 + TAG_LEN + 1 + loginUtf8.length;
int cap = TAG_LEN + 1 + loginUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putShort(TYPE);
bb.putShort(VER);
bb.putShort(SUBTYPE_COMPAT);
bb.put(TAG_ASCII); // [TAG_LEN]
bb.put((byte) loginUtf8.length); // [1]
bb.put(loginUtf8); // [N]
bb.put(TAG_ASCII);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
return bb.array();
}
@ -140,8 +120,7 @@ public final class HeaderBody implements BodyRecord {
public String toString() {
return """
HeaderBody {
тип записи : HEADER (type=0, ver=1)
ожидаемая линия : 0 (genesis)
тип записи : HEADER (type=0, ver=1) [в заголовке блока]
subType : 0 (compat)
тег формата : "%s"
login владельца : "%s"

View File

@ -1,6 +1,9 @@
// =======================
// blockchain/body/ReactionBody.java (ИЗМЕНЁННЫЙ: bodyBytes без type/subType/version, НЕТ линейных полей)
// =======================
package blockchain.body;
import blockchain.LineIndex;
import shine.db.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
@ -9,22 +12,18 @@ import java.util.Arrays;
import java.util.Objects;
/**
* ReactionBody type=2, version=1.
* ReactionBody type=2, version=1 (в заголовке блока).
*
* Формат bodyBytes (BigEndian):
* [2] type=2
* [2] ver=1
*
* [2] subType (uint16) подтип реакции
* 1 = LIKE (лайк)
* subType (в заголовке блока):
* 1 = LIKE
*
* bodyBytes (BigEndian), новый формат:
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes)
*
* ЛИНИЯ:
* - строго lineIndex=2
* ЛИНИИ НЕТ.
*/
public final class ReactionBody implements BodyRecord, BodyHasTarget {
@ -33,40 +32,34 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
// subType:
public static final short SUB_LIKE = 1;
public final short subType;
public final short subType; // из header
public final short version; // из header
public final String toBlockchainName;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
/** Десериализация из полного bodyBytes (включая type/version/subType). */
public ReactionBody(byte[] bodyBytes) {
public ReactionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
// минимум: 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");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ReactionBody version must be 1, got=" + (this.version & 0xFFFF));
}
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.subType = bb.getShort();
if (this.subType != SUB_LIKE) {
if ((this.subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF)) {
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
}
// минимум: nameLen[1]+name[1]+global[4]+hash[32]
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
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);
@ -77,46 +70,28 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
// запрет мусора в конце
if (bb.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
/** Создание “вручную”. */
public ReactionBody(short subType,
String toBlockchainName,
int toBlockGlobalNumber,
byte[] toBlockHash32) {
public ReactionBody(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));
this.subType = MsgSubType.REACTION_LIKE;
this.version = VER;
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.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 subType() { return subType; }
@Override
public short expectedLineIndex() {
return LineIndex.REACTION;
}
@Override
public ReactionBody check() {
if (subType != SUB_LIKE)
if ((subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF))
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
if (toBlockchainName == null || toBlockchainName.isBlank())
@ -135,16 +110,9 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
if (nameBytes.length == 0 || nameBytes.length > 255)
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
// type[2]+ver[2]+subType[2] + nameLen[1]+name[N] + global[4] + hash[32]
int cap = 2 + 2 + 2 + 1 + nameBytes.length + 4 + 32;
int cap = 1 + nameBytes.length + 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putShort(TYPE);
bb.putShort(VER);
bb.putShort(subType);
bb.put((byte) nameBytes.length);
bb.put(nameBytes);
bb.putInt(toBlockGlobalNumber);
@ -153,43 +121,8 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
return bb.array();
}
@Override
public String toString() {
String st = (subType == SUB_LIKE) ? "LIKE (1)" : "UNKNOWN";
/* ====================== BodyHasTarget ====================== */
return """
ReactionBody {
тип записи : REACTION (type=2, ver=1)
ожидаемая линия : 2
subType : %s
целевой блокчейн : "%s"
globalNumber цели : %d
hash цели (hex) : %s
}
""".formatted(
st,
toBlockchainName,
toBlockGlobalNumber,
toBlockHashHex()
);
}
public String toBlockHashHex() {
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);
}
/* ===================================================================== */
/* ====================== BodyHasTarget контракт ========================= */
/* ===================================================================== */
/** В самом формате ReactionBody login цели не хранится => null. */
@Override public String toLogin() { return null; }
@Override public String toBchName() { return toBlockchainName; }

View File

@ -1,6 +1,9 @@
// =======================
// blockchain/body/TextBody.java (ИЗМЕНЁННЫЙ: header содержит type/subType/version, body содержит line fields)
// =======================
package blockchain.body;
import blockchain.LineIndex;
import shine.db.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
@ -11,98 +14,87 @@ import java.util.Arrays;
import java.util.Objects;
/**
* TextBody type=1, ver=1.
* TextBody type=1, ver=1 (в заголовке блока).
*
* Формат bodyBytes (BigEndian):
* [2] type=1
* [2] ver=1
* subType (в заголовке блока):
* 1 = NEW
* 2 = REPLY
* 3 = REPOST
* 10 = EDIT
*
* [2] subType (uint16): подтип текстового сообщения
* 1 = новое сообщение (начало ветки)
* 2 = ответ на сообщение (reply)
* 3 = репост (repost)
* 10 = редактирование (edit) <-- ВАЖНО: как на сервере/в БД-триггере
* bodyBytes (BigEndian), новый формат:
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
*
* [2] textLenBytes (uint16) длина текста в байтах UTF-8
* [2] textLenBytes (uint16)
* [N] text UTF-8
*
* Далее ТОЛЬКО если subType == 2 или subType == 3 или subType == 10:
* Далее ТОЛЬКО если subType == REPLY/REPOST/EDIT:
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes)
*
* ЛИНИЯ:
* - строго lineIndex=1
*/
public final class TextBody implements BodyRecord, BodyHasTarget {
public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine {
public static final short TYPE = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
// subType:
public static final short SUB_NEW = 1;
public static final short SUB_REPLY = 2;
public static final short SUB_REPOST = 3;
public final short subType; // из header
public final short version; // из header
/** ВАЖНО: EDIT как на сервере (и как ожидает trg_blocks_edit_apply_ai). */
public static final short SUB_EDIT = 10;
// линейные поля
public final int prevLineNumber;
public final byte[] prevLineHash32; // 32
public final int thisLineNumber;
/** Подтип текстового сообщения (1/2/3/10). */
public final short subType;
/** Текст сообщения (строго валидный UTF-8, не пустой/не blank). */
// payload
public final String message;
// Заполняются только если subType == SUB_REPLY || SUB_REPOST || SUB_EDIT
// target (только для reply/repost/edit)
public final String toBlockchainName;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
/* ===================================================================== */
/* ====================== Конструктор из байт =========================== */
/* ===================================================================== */
/** Десериализация из полного bodyBytes (включая type/version). */
public TextBody(byte[] bodyBytes) {
public TextBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
// минимум: type+ver (4) + subType(2) + textLen(2)
if (bodyBytes.length < 4 + 2 + 2) {
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextBody version must be 1, got=" + (this.version & 0xFFFF));
}
if (!isValidSubType(this.subType)) {
throw new IllegalArgumentException("Bad Text subType: " + (this.subType & 0xFFFF));
}
// минимум: line(4+32+4) + textLen(2)
if (bodyBytes.length < 4 + 32 + 4 + 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) {
throw new IllegalArgumentException("Not TextBody: type=" + type + " ver=" + ver);
}
this.prevLineNumber = bb.getInt();
this.subType = bb.getShort();
if (this.subType != SUB_NEW
&& this.subType != SUB_REPLY
&& this.subType != SUB_REPOST
&& this.subType != SUB_EDIT) {
throw new IllegalArgumentException("Bad subType: " + (this.subType & 0xFFFF));
}
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
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 + ")");
}
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()
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
@ -112,22 +104,16 @@ public final class TextBody implements BodyRecord, BodyHasTarget {
throw new IllegalArgumentException("Text payload is not valid UTF-8", e);
}
if (this.message.isBlank()) {
throw new IllegalArgumentException("Text message is blank");
}
if (this.message.isBlank()) throw new IllegalArgumentException("Text message is blank");
// Поля ссылки только для reply/repost/edit
if (this.subType == SUB_REPLY || this.subType == SUB_REPOST || this.subType == SUB_EDIT) {
if (bb.remaining() < 1) {
throw new IllegalArgumentException("Missing toBlockchainNameLen");
}
// target only for reply/repost/edit
if (isHasTargetSubType(this.subType)) {
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) {
if (bb.remaining() < nameLen + 4 + 32)
throw new IllegalArgumentException("Reply/Repost/Edit payload too short");
}
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
@ -138,110 +124,92 @@ public final class TextBody implements BodyRecord, BodyHasTarget {
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
// Запрет мусора в конце
if (bb.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
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());
}
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail for subType=NEW, remaining=" + bb.remaining());
}
}
/* ===================================================================== */
/* ====================== Конструкторы “для тестов” ====================== */
/* ===================================================================== */
public TextBody(String 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) или subType=EDIT (10) со ссылкой на блок. */
public TextBody(short subType,
public TextBody(int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
short subType,
String message,
String toBlockchainName,
int toBlockGlobalNumber,
Integer 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 && subType != SUB_EDIT) {
throw new IllegalArgumentException("subType must be SUB_REPLY or SUB_REPOST or SUB_EDIT for this constructor");
}
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF));
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
this.thisLineNumber = thisLineNumber;
this.subType = subType;
this.version = VER;
this.message = message;
if (isHasTargetSubType(subType)) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
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);
} else {
this.toBlockchainName = null;
this.toBlockGlobalNumber = 0;
this.toBlockHash32 = null;
}
}
/* ===================================================================== */
/* ====================== BodyRecord контракт =========================== */
/* ===================================================================== */
private static boolean isValidSubType(short st) {
int v = st & 0xFFFF;
return v == (MsgSubType.TEXT_NEW & 0xFFFF)
|| v == (MsgSubType.TEXT_REPLY & 0xFFFF)
|| v == (MsgSubType.TEXT_REPOST & 0xFFFF)
|| v == (MsgSubType.TEXT_EDIT & 0xFFFF);
}
@Override public short type() { return TYPE; }
@Override public short version() { return VER; }
@Override public short subType() { return subType; }
@Override
public short expectedLineIndex() {
return LineIndex.TEXT;
private static boolean isHasTargetSubType(short st) {
int v = st & 0xFFFF;
return v == (MsgSubType.TEXT_REPLY & 0xFFFF)
|| v == (MsgSubType.TEXT_REPOST & 0xFFFF)
|| v == (MsgSubType.TEXT_EDIT & 0xFFFF);
}
@Override
public TextBody check() {
if (subType != SUB_NEW && subType != SUB_REPLY && subType != SUB_REPOST && subType != SUB_EDIT) {
throw new IllegalArgumentException("Bad subType: " + (subType & 0xFFFF));
}
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF));
if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank");
if (message == null || message.isBlank()) {
throw new IllegalArgumentException("Text message is blank");
}
if (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) {
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");
// line fields rule:
if (prevLineNumber == -1) {
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
} else {
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");
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
// thisLineNumber сервер пока не проверяет (принимаем как есть)
}
if (isHasTargetSubType(subType)) {
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 {
if (toBlockchainName != null || toBlockHash32 != null) throw new IllegalArgumentException("SUB_NEW must not contain target fields");
}
return this;
@ -250,46 +218,34 @@ public final class TextBody implements BodyRecord, BodyHasTarget {
@Override
public byte[] toBytes() {
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)");
}
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;
int cap = 4 + 32 + 4 // line fields
+ 2 + msgUtf8.length; // text
byte[] nameBytes = null;
if (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) {
if (isHasTargetSubType(subType)) {
nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameBytes.length == 0 || nameBytes.length > 255) {
if (nameBytes.length == 0 || nameBytes.length > 255)
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
}
if (toBlockHash32 == null || toBlockHash32.length != 32) {
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 != 32");
}
cap += 1 + nameBytes.length + 4 + 32;
} else {
if (toBlockchainName != null || toBlockHash32 != null) {
throw new IllegalArgumentException("SUB_NEW must not contain reply/repost/edit fields");
}
}
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putShort(TYPE);
bb.putShort(VER);
bb.putShort(subType);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
if (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) {
if (isHasTargetSubType(subType)) {
bb.put((byte) nameBytes.length);
bb.put(nameBytes);
bb.putInt(toBlockGlobalNumber);
@ -299,83 +255,32 @@ public final class TextBody implements BodyRecord, BodyHasTarget {
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)";
case SUB_EDIT -> "EDIT (10)";
default -> "UNKNOWN";
};
if (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) {
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()
);
private static boolean isAllZero32(byte[] b) {
if (b == null || b.length != 32) return true;
for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
return true;
}
return """
TextBody {
тип записи : TEXT (type=1, ver=1)
ожидаемая линия : 1
subType : %s
длина сообщения : %d байт
текст сообщения : "%s"
}
""".formatted(
st,
message.getBytes(StandardCharsets.UTF_8).length,
message
);
}
/* ====================== BodyHasLine ====================== */
@Override public int prevLineNumber() { return prevLineNumber; }
@Override public byte[] prevLineHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
@Override public int thisLineNumber() { return thisLineNumber; }
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);
}
/* ===================================================================== */
/* ====================== BodyHasTarget контракт ========================= */
/* ===================================================================== */
/** В формате TextBody login цели не хранится => null. */
/* ====================== BodyHasTarget ===================== */
@Override public String toLogin() { return null; }
@Override
public String toBchName() {
return (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) ? toBlockchainName : null;
return isHasTargetSubType(subType) ? toBlockchainName : null;
}
@Override
public Integer toBlockGlobalNumber() {
return (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) ? toBlockGlobalNumber : null;
return isHasTargetSubType(subType) ? toBlockGlobalNumber : null;
}
@Override
public byte[] toBlockHasheBytes() {
return (subType == SUB_REPLY || subType == SUB_REPOST || subType == SUB_EDIT) ? toBlockHash32 : null;
return isHasTargetSubType(subType) ? toBlockHash32 : null;
}
}

View File

@ -1,87 +1,79 @@
// =======================
// blockchain/body/UserParamBody.java (ИЗМЕНЁННЫЙ: bodyBytes без type/subType/version, + line fields)
// =======================
package blockchain.body;
import blockchain.LineIndex;
import shine.db.MsgSubType;
import java.nio.ByteBuffer;
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;
/**
* UserParamBody type=4, ver=1. (Параметр профиля / данные пользователя о себе)
* UserParamBody type=4, ver=1 (в заголовке блока).
*
* Идея:
* - Это "пользователь сам заявил параметр X со значением Y".
* - Один блок = один параметр (одна пара key/value).
* (Если нужно больше параметров просто добавляешь несколько блоков подряд).
* subType (в заголовке блока):
* 1 = TEXT_TEXT
*
* Формат bodyBytes (BigEndian):
* [2] type=4
* [2] ver=1
* bodyBytes (BigEndian), новый формат:
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
*
* [2] subType (uint16)
* 1 = TEXT_TEXT (ключ-значение, обе строки UTF-8)
*
* [2] keyLenBytes (uint16) длина ключа в байтах UTF-8
* [2] keyLenBytes (uint16)
* [N] keyUtf8
*
* [2] valueLenBytes (uint16) длина значения в байтах UTF-8
* [2] valueLenBytes (uint16)
* [M] valueUtf8
*
* ВАЖНО:
* - длины именно В БАЙТАХ UTF-8 (не в символах)
* - ключ и значение обязаны быть валидным UTF-8
* - ключ запрещаем пустым/blank (иначе нельзя идентифицировать параметр)
* - значение может быть пустым? (реши сам)
* сейчас: запрещаем пустое (len>0) и запрещаем blank, чтобы не мусорить цепочку
*
* ЛИНИЯ:
* - строго lineIndex=4 (выделенная линия под пользовательские параметры/профиль).
*/
public final class UserParamBody implements BodyRecord {
public final class UserParamBody implements BodyRecord, BodyHasLine {
public static final short TYPE = 4;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
// subType:
public static final short SUB_TEXT_TEXT = 1;
public final short subType; // из header
public final short version; // из header
public final short subType;
// line
public final int prevLineNumber;
public final byte[] prevLineHash32;
public final int thisLineNumber;
/** Название параметра (пример: "firstName", "lastName", "address", "about"). */
public final String paramKey;
/** Значение параметра (пример: "Aidar", "Gareev", "..."). */
public final String paramValue;
/* ===================================================================== */
/* ====================== Конструктор из байт =========================== */
/* ===================================================================== */
public UserParamBody(byte[] bodyBytes) {
public UserParamBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
// минимум: type[2]+ver[2]+subType[2]+keyLen[2]+key[1]+valLen[2]+val[1]
if (bodyBytes.length < 2 + 2 + 2 + 2 + 1 + 2 + 1) {
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("UserParamBody version must be 1, got=" + (this.version & 0xFFFF));
}
if ((this.subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF)) {
throw new IllegalArgumentException("Bad UserParam subType: " + (this.subType & 0xFFFF));
}
// минимум: line(4+32+4) + keyLen(2)+key(1) + valLen(2)+val(1)
if (bodyBytes.length < (4 + 32 + 4) + 2 + 1 + 2 + 1) {
throw new IllegalArgumentException("UserParamBody 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 UserParamBody: type=" + type + " ver=" + ver);
}
this.prevLineNumber = bb.getInt();
this.subType = bb.getShort();
if (this.subType != SUB_TEXT_TEXT) {
throw new IllegalArgumentException("Bad UserParam subType: " + (this.subType & 0xFFFF));
}
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
int keyLen = Short.toUnsignedInt(bb.getShort());
if (keyLen <= 0) throw new IllegalArgumentException("paramKeyLen is 0");
@ -97,31 +89,30 @@ public final class UserParamBody implements BodyRecord {
byte[] valBytes = new byte[valLen];
bb.get(valBytes);
// запрет мусора в конце
if (bb.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
this.paramKey = strictUtf8(keyBytes, "paramKey");
this.paramValue = strictUtf8(valBytes, "paramValue");
if (this.paramKey.isBlank()) {
throw new IllegalArgumentException("paramKey is blank");
}
if (this.paramValue.isBlank()) {
throw new IllegalArgumentException("paramValue is blank");
}
if (this.paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank");
if (this.paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank");
}
/* ===================================================================== */
/* ====================== Конструктор “вручную” ========================= */
/* ===================================================================== */
public UserParamBody(int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
String paramKey,
String paramValue) {
public UserParamBody(String paramKey, String paramValue) {
Objects.requireNonNull(paramKey, "paramKey == null");
Objects.requireNonNull(paramValue, "paramValue == null");
this.subType = SUB_TEXT_TEXT;
this.subType = MsgSubType.USER_PARAM_TEXT_TEXT;
this.version = VER;
this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
this.thisLineNumber = thisLineNumber;
if (paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank");
if (paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank");
@ -130,55 +121,41 @@ public final class UserParamBody implements BodyRecord {
this.paramValue = paramValue;
}
/* ===================================================================== */
/* ====================== BodyRecord контракт =========================== */
/* ===================================================================== */
@Override public short type() { return TYPE; }
@Override public short version() { return VER; }
@Override public short subType() { return subType; }
@Override
public short expectedLineIndex() {
return LineIndex.USER_PARAM;
}
@Override
public UserParamBody check() {
if (subType != SUB_TEXT_TEXT)
if ((subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF))
throw new IllegalArgumentException("Bad UserParam subType: " + (subType & 0xFFFF));
if (paramKey == null || paramKey.isBlank())
throw new IllegalArgumentException("paramKey is blank");
if (paramValue == null || paramValue.isBlank())
throw new IllegalArgumentException("paramValue is blank");
if (prevLineNumber == -1) {
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
} else {
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
}
if (paramKey == null || paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank");
if (paramValue == null || paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank");
return this;
}
@Override
public byte[] toBytes() {
if (subType != SUB_TEXT_TEXT)
throw new IllegalArgumentException("Bad UserParam subType: " + (subType & 0xFFFF));
byte[] keyUtf8 = paramKey.getBytes(StandardCharsets.UTF_8);
byte[] valUtf8 = paramValue.getBytes(StandardCharsets.UTF_8);
if (keyUtf8.length == 0) throw new IllegalArgumentException("paramKey utf8 len is 0");
if (valUtf8.length == 0) throw new IllegalArgumentException("paramValue utf8 len is 0");
if (keyUtf8.length == 0 || keyUtf8.length > 65535) throw new IllegalArgumentException("paramKey utf8 len must be 1..65535");
if (valUtf8.length == 0 || valUtf8.length > 65535) throw new IllegalArgumentException("paramValue utf8 len must be 1..65535");
if (keyUtf8.length > 65535) throw new IllegalArgumentException("paramKey too long (>65535 bytes)");
if (valUtf8.length > 65535) throw new IllegalArgumentException("paramValue too long (>65535 bytes)");
// type[2]+ver[2]+subType[2] + keyLen[2]+key[N] + valLen[2]+val[M]
int cap = 2 + 2 + 2 + 2 + keyUtf8.length + 2 + valUtf8.length;
int cap = (4 + 32 + 4)
+ 2 + keyUtf8.length
+ 2 + valUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putShort(TYPE);
bb.putShort(VER);
bb.putShort(SUB_TEXT_TEXT);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.putShort((short) keyUtf8.length);
bb.put(keyUtf8);
@ -189,28 +166,8 @@ public final class UserParamBody implements BodyRecord {
return bb.array();
}
@Override
public String toString() {
String st = (subType == SUB_TEXT_TEXT) ? "TEXT_TEXT (1)" : "UNKNOWN";
return """
UserParamBody {
тип записи : USER_PARAM (type=4, ver=1)
ожидаемая линия : 4
subType : %s
paramKey : "%s"
paramValue : "%s"
}
""".formatted(st, paramKey, paramValue);
}
/* ===================================================================== */
/* =========================== Helpers ================================== */
/* ===================================================================== */
private static String strictUtf8(byte[] bytes, String fieldName) {
var decoder = StandardCharsets.UTF_8
.newDecoder()
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
@ -220,4 +177,15 @@ public final class UserParamBody implements BodyRecord {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static boolean isAllZero32(byte[] b) {
if (b == null || b.length != 32) return true;
for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
return true;
}
/* ====================== BodyHasLine ====================== */
@Override public int prevLineNumber() { return prevLineNumber; }
@Override public byte[] prevLineHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
@Override public int thisLineNumber() { return thisLineNumber; }
}

View File

@ -1,5 +1,5 @@
// =======================
// BlockchainStateDAO.java (НОВАЯ ВЕРСИЯ)
// shine/db/dao/BlockchainStateDAO.java (ИЗМЕНЁННАЯ: убраны line0..7, last_block_*)
// =======================
package shine.db.dao;
@ -40,17 +40,9 @@ public final class BlockchainStateDAO {
blockchain_key,
size_limit,
file_size_bytes,
last_global_number,
last_global_hash,
updated_at_ms,
line0_last_number, line0_last_hash,
line1_last_number, line1_last_hash,
line2_last_number, line2_last_hash,
line3_last_number, line3_last_hash,
line4_last_number, line4_last_hash,
line5_last_number, line5_last_hash,
line6_last_number, line6_last_hash,
line7_last_number, line7_last_hash
last_block_number,
last_block_hash,
updated_at_ms
FROM blockchain_state
WHERE blockchain_name = ?
""";
@ -73,10 +65,6 @@ public final class BlockchainStateDAO {
/** UPSERT с внешним соединением. Соединение НЕ закрывает. */
public void upsert(Connection c, BlockchainStateEntry e) throws SQLException {
// Колонок ровно 24:
// 8 основных + (8 линий * 2 поля) = 24
String sql = """
INSERT INTO blockchain_state (
blockchain_name,
@ -84,53 +72,19 @@ public final class BlockchainStateDAO {
blockchain_key,
size_limit,
file_size_bytes,
last_global_number,
last_global_hash,
updated_at_ms,
line0_last_number, line0_last_hash,
line1_last_number, line1_last_hash,
line2_last_number, line2_last_hash,
line3_last_number, line3_last_hash,
line4_last_number, line4_last_hash,
line5_last_number, line5_last_hash,
line6_last_number, line6_last_hash,
line7_last_number, line7_last_hash
) VALUES (
?,?,?,?,?,?,?,?,
?,?,
?,?,
?,?,
?,?,
?,?,
?,?,
?,?,
?,?
)
last_block_number,
last_block_hash,
updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(blockchain_name)
DO UPDATE SET
login = excluded.login,
blockchain_key = excluded.blockchain_key,
size_limit = excluded.size_limit,
file_size_bytes = excluded.file_size_bytes,
last_global_number = excluded.last_global_number,
last_global_hash = excluded.last_global_hash,
updated_at_ms = excluded.updated_at_ms,
line0_last_number = excluded.line0_last_number,
line0_last_hash = excluded.line0_last_hash,
line1_last_number = excluded.line1_last_number,
line1_last_hash = excluded.line1_last_hash,
line2_last_number = excluded.line2_last_number,
line2_last_hash = excluded.line2_last_hash,
line3_last_number = excluded.line3_last_number,
line3_last_hash = excluded.line3_last_hash,
line4_last_number = excluded.line4_last_number,
line4_last_hash = excluded.line4_last_hash,
line5_last_number = excluded.line5_last_number,
line5_last_hash = excluded.line5_last_hash,
line6_last_number = excluded.line6_last_number,
line6_last_hash = excluded.line6_last_hash,
line7_last_number = excluded.line7_last_number,
line7_last_hash = excluded.line7_last_hash
last_block_number= excluded.last_block_number,
last_block_hash = excluded.last_block_hash,
updated_at_ms = excluded.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
@ -143,14 +97,10 @@ public final class BlockchainStateDAO {
ps.setLong(i++, e.getSizeLimit());
ps.setLong(i++, e.getFileSizeBytes());
ps.setInt(i++, e.getLastGlobalNumber());
setBytesNullable(ps, i++, e.getLastGlobalHash());
ps.setLong(i++, e.getUpdatedAtMs());
ps.setInt(i++, e.getLastBlockNumber());
setBytesNullable(ps, i++, e.getLastBlockHash());
for (int line = 0; line < 8; line++) {
ps.setInt(i++, e.getLastLineNumber(line));
setBytesNullable(ps, i++, e.getLastLineHash(line));
}
ps.setLong(i++, e.getUpdatedAtMs());
ps.executeUpdate();
}
@ -175,24 +125,10 @@ public final class BlockchainStateDAO {
ps.setLong(2, nowMs);
ps.setString(3, blockchainName);
ps.setLong(4, deltaBytes);
int updated = ps.executeUpdate();
return updated > 0;
return ps.executeUpdate() > 0;
}
}
/** Удобная проверка для HEADER: запись должна быть и last_global_number должен быть -1. */
public BlockchainStateEntry requireExistingAtGenesis(Connection c, String blockchainName) throws SQLException {
BlockchainStateEntry st = getByBlockchainName(c, blockchainName);
if (st == null) {
throw new IllegalStateException("Blockchain state not found for blockchainName=" + blockchainName);
}
if (st.getLastGlobalNumber() != -1) {
throw new IllegalStateException("Blockchain state is not at genesis (-1). blockchainName=" + blockchainName +
" last_global_number=" + st.getLastGlobalNumber());
}
return st;
}
private BlockchainStateEntry mapRow(ResultSet rs) throws SQLException {
BlockchainStateEntry e = new BlockchainStateEntry();
@ -203,16 +139,11 @@ public final class BlockchainStateDAO {
e.setSizeLimit(rs.getLong("size_limit"));
e.setFileSizeBytes(rs.getLong("file_size_bytes"));
e.setLastGlobalNumber(rs.getInt("last_global_number"));
e.setLastGlobalHash(rs.getBytes("last_global_hash")); // может быть null
e.setLastBlockNumber(rs.getInt("last_block_number"));
e.setLastBlockHash(rs.getBytes("last_block_hash")); // nullable
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
for (int line = 0; line < 8; line++) {
e.setLastLineNumber(line, rs.getInt("line" + line + "_last_number"));
e.setLastLineHash(line, rs.getBytes("line" + line + "_last_hash")); // может быть null
}
return e;
}

View File

@ -1,3 +1,6 @@
// =======================
// shine/db/dao/BlocksDAO.java (ИЗМЕНЁННЫЙ под новый blocks формат + линейная проверка)
// =======================
package shine.db.dao;
import shine.db.SqliteDbController;
@ -6,14 +9,14 @@ import shine.db.entities.BlockEntry;
import java.sql.*;
/**
* DAO для таблицы blocks.
* DAO для таблицы blocks (новый формат).
*
* Правило:
* - методы с Connection НЕ закрывают соединение
* - методы без Connection сами открывают и закрывают соединение
*
* Важно:
* - PRIMARY KEY удалён (временно), поэтому "upsert" сделан через UPDATE->INSERT.
* Ключ:
* - (bch_name, block_number) уникальная пара в рамках общей БД сервера.
*/
public final class BlocksDAO {
@ -39,26 +42,62 @@ public final class BlocksDAO {
INSERT INTO blocks (
login,
bch_name,
block_global_number,
block_global_pre_hashe,
block_line_index,
block_line_number,
block_line_pre_hashe,
block_number,
msg_type,
msg_sub_type,
block_byte,
block_bytes,
to_login,
to_bch_name,
to_block_global_number,
to_block_hashe,
to_block_number,
to_block_hash,
block_hash,
block_signature,
edited_by_block_global_number
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
edited_by_block_number,
prev_line_number,
prev_line_hash,
this_line_number
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
bindAll(ps, e);
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.BLOB);
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);
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.BLOB);
if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber());
else ps.setNull(i++, Types.INTEGER);
ps.executeUpdate();
}
}
@ -70,63 +109,62 @@ public final class BlocksDAO {
}
}
// -------------------- UPSERT (UPDATE -> INSERT) --------------------
public void upsert(Connection c, BlockEntry e) throws SQLException {
int updated = update(c, e);
if (updated == 0) insert(c, e);
}
public void upsert(BlockEntry e) throws SQLException {
try (Connection c = db.getConnection()) {
upsert(c, e);
}
}
// -------------------- SELECT --------------------
public BlockEntry getByPk(Connection c,
String login,
String bchName,
int blockGlobalNumber,
int blockLineIndex,
int blockLineNumber) throws SQLException {
// -------------------- SELECT: HASH BY NUMBER --------------------
/** Получить block_hash по (bch_name, block_number). Нужен для линейной проверки. */
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
String sql = """
SELECT
login,
bch_name,
block_global_number,
block_global_pre_hashe,
block_line_index,
block_line_number,
block_line_pre_hashe,
msg_type,
msg_sub_type,
block_byte,
to_login,
to_bch_name,
to_block_global_number,
to_block_hashe,
block_hash,
block_signature,
edited_by_block_global_number
SELECT block_hash
FROM blocks
WHERE
login = ?
AND bch_name = ?
AND block_global_number = ?
AND block_line_index = ?
AND block_line_number = ?
WHERE bch_name = ? AND block_number = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setString(2, bchName);
ps.setInt(3, blockGlobalNumber);
ps.setInt(4, blockLineIndex);
ps.setInt(5, blockLineNumber);
ps.setString(1, bchName);
ps.setInt(2, blockNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return rs.getBytes("block_hash");
}
}
}
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException {
try (Connection c = db.getConnection()) {
return getHashByNumber(c, bchName, blockNumber);
}
}
// -------------------- SELECT: FULL ENTRY --------------------
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,
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;
@ -135,205 +173,57 @@ public final class BlocksDAO {
}
}
public BlockEntry getByPk(String login,
String bchName,
int blockGlobalNumber,
int blockLineIndex,
int blockLineNumber) throws SQLException {
public BlockEntry getByNumber(String bchName, int blockNumber) throws SQLException {
try (Connection c = db.getConnection()) {
return getByPk(c, login, bchName, blockGlobalNumber, blockLineIndex, blockLineNumber);
}
}
// -------------------- UPDATE --------------------
public int update(Connection c, BlockEntry e) throws SQLException {
String sql = """
UPDATE blocks
SET
block_global_pre_hashe = ?,
block_line_pre_hashe = ?,
msg_type = ?,
msg_sub_type = ?,
block_byte = ?,
to_login = ?,
to_bch_name = ?,
to_block_global_number = ?,
to_block_hashe = ?,
block_hash = ?,
block_signature = ?,
edited_by_block_global_number = ?
WHERE
login = ?
AND bch_name = ?
AND block_global_number = ?
AND block_line_index = ?
AND block_line_number = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
ps.setBytes(i++, bb(e.getBlockGlobalPreHashe()));
ps.setBytes(i++, bb(e.getBlockLinePreHashe()));
ps.setInt(i++, e.getMsgType());
ps.setInt(i++, e.getMsgSubType());
byte[] bytes = e.getBlockByte();
if (bytes != null) ps.setBytes(i++, bytes);
else ps.setNull(i++, Types.BLOB);
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.getToBlockGlobalNumber() != null) ps.setInt(i++, e.getToBlockGlobalNumber());
else ps.setNull(i++, Types.INTEGER);
if (e.getToBlockHashe() != null) ps.setBytes(i++, e.getToBlockHashe());
else ps.setNull(i++, Types.BLOB);
ps.setBytes(i++, bb(e.getBlockHash()));
ps.setBytes(i++, bb(e.getBlockSignature()));
if (e.getEditedByBlockGlobalNumber() != null) ps.setInt(i++, e.getEditedByBlockGlobalNumber());
else ps.setNull(i++, Types.INTEGER);
ps.setString(i++, e.getLogin());
ps.setString(i++, e.getBchName());
ps.setInt(i++, e.getBlockGlobalNumber());
ps.setInt(i++, e.getBlockLineIndex());
ps.setInt(i++, e.getBlockLineNumber());
return ps.executeUpdate();
}
}
public int update(BlockEntry e) throws SQLException {
try (Connection c = db.getConnection()) {
return update(c, e);
}
}
// -------------------- DELETE --------------------
public int deleteByPk(Connection c,
String login,
String bchName,
int blockGlobalNumber,
int blockLineIndex,
int blockLineNumber) throws SQLException {
String sql = """
DELETE FROM blocks
WHERE
login = ?
AND bch_name = ?
AND block_global_number = ?
AND block_line_index = ?
AND block_line_number = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setString(2, bchName);
ps.setInt(3, blockGlobalNumber);
ps.setInt(4, blockLineIndex);
ps.setInt(5, blockLineNumber);
return ps.executeUpdate();
}
}
public int deleteByPk(String login,
String bchName,
int blockGlobalNumber,
int blockLineIndex,
int blockLineNumber) throws SQLException {
try (Connection c = db.getConnection()) {
return deleteByPk(c, login, bchName, blockGlobalNumber, blockLineIndex, blockLineNumber);
return getByNumber(c, bchName, blockNumber);
}
}
// -------------------- INTERNAL --------------------
private static void bindAll(PreparedStatement ps, BlockEntry e) throws SQLException {
int i = 1;
ps.setString(i++, e.getLogin());
ps.setString(i++, e.getBchName());
ps.setInt(i++, e.getBlockGlobalNumber());
ps.setBytes(i++, bb(e.getBlockGlobalPreHashe()));
ps.setInt(i++, e.getBlockLineIndex());
ps.setInt(i++, e.getBlockLineNumber());
ps.setBytes(i++, bb(e.getBlockLinePreHashe()));
ps.setInt(i++, e.getMsgType());
ps.setInt(i++, e.getMsgSubType());
byte[] bytes = e.getBlockByte();
if (bytes != null) ps.setBytes(i++, bytes);
else ps.setNull(i++, Types.BLOB);
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.getToBlockGlobalNumber() != null) ps.setInt(i++, e.getToBlockGlobalNumber());
else ps.setNull(i++, Types.INTEGER);
if (e.getToBlockHashe() != null) ps.setBytes(i++, e.getToBlockHashe());
else ps.setNull(i++, Types.BLOB);
ps.setBytes(i++, bb(e.getBlockHash()));
ps.setBytes(i++, bb(e.getBlockSignature()));
if (e.getEditedByBlockGlobalNumber() != null) ps.setInt(i++, e.getEditedByBlockGlobalNumber());
else ps.setNull(i++, Types.INTEGER);
}
private BlockEntry mapRow(ResultSet rs) throws SQLException {
BlockEntry e = new BlockEntry();
e.setLogin(rs.getString("login"));
e.setBchName(rs.getString("bch_name"));
e.setBlockGlobalNumber(rs.getInt("block_global_number"));
e.setBlockGlobalPreHashe(rs.getBytes("block_global_pre_hashe"));
e.setBlockLineIndex(rs.getInt("block_line_index"));
e.setBlockLineNumber(rs.getInt("block_line_number"));
e.setBlockLinePreHashe(rs.getBytes("block_line_pre_hashe"));
e.setBlockNumber(rs.getInt("block_number"));
e.setMsgType(rs.getInt("msg_type"));
e.setMsgSubType(rs.getInt("msg_sub_type"));
e.setBlockByte(rs.getBytes("block_byte"));
e.setBlockBytes(rs.getBytes("block_bytes"));
e.setToLogin(rs.getString("to_login"));
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 toBlockGlobalNumber = (Integer) rs.getObject("to_block_global_number");
e.setToBlockGlobalNumber(toBlockGlobalNumber);
Integer toBlockNumber = (Integer) rs.getObject("to_block_number");
e.setToBlockNumber(toBlockNumber);
byte[] toBlockHashe = rs.getBytes("to_block_hashe");
if (rs.wasNull()) toBlockHashe = null;
e.setToBlockHashe(toBlockHashe);
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_global_number");
e.setEditedByBlockGlobalNumber(editedBy);
Integer editedBy = (Integer) rs.getObject("edited_by_block_number");
e.setEditedByBlockNumber(editedBy);
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);
return e;
}
private static byte[] bb(byte[] b) { return b == null ? new byte[0] : b; }
}

View File

@ -9,7 +9,7 @@ import java.sql.*;
/**
* UserCreateDAO атомарное добавление пользователя:
* - solana_users (login, device_key)
* - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_global_number=-1 ...)
* - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...)
*
* ВАЖНО:
* - только INSERT/UPSERT
@ -67,14 +67,9 @@ public final class UserCreateDAO {
st.setSizeLimit(sizeLimit);
st.setFileSizeBytes(0L);
// старт: глобальных блоков ещё нет
st.setLastGlobalNumber(-1);
st.setLastGlobalHash(null);
for (int line = 0; line < 8; line++) {
st.setLastLineNumber(line, 0);
st.setLastLineHash(line, null);
}
// старт: блоков ещё нет
st.setLastBlockNumber(-1);
st.setLastBlockHash(null);
st.setUpdatedAtMs(nowMs);

View File

@ -1,93 +1,62 @@
// =======================
// shine/db/entities/BlockEntry.java (ИЗМЕНЁННАЯ под новый blocks формат)
// =======================
package shine.db.entities;
/**
* Запись блока (таблица blocks).
* Запись блока (таблица blocks) обновлённая модель под новый формат.
*
* Храним:
* - login, bch_name (как было в проекте, чтобы не ломать общую БД)
* - block_number (глобальный номер в этой цепочке)
* - block_bytes (полный блок: preimage + signature)
* - block_hash (32 байта вычисленный SHA-256(preimage))
* - block_signature (64 байта)
*
* Опционально:
* - prev_line_number / prev_line_hash / this_line_number
*
* Плюс поля индексации (как раньше было удобно):
* - msg_type / msg_sub_type
* - to_* (если есть target)
* - edited_by_block_number (для TEXT_EDIT)
*/
public class BlockEntry {
private String login;
private String bchName;
private int blockGlobalNumber;
private byte[] blockGlobalPreHashe;
private int blockLineIndex;
private int blockLineNumber;
private byte[] blockLinePreHashe;
private int blockNumber;
private int msgType;
private int msgSubType;
private byte[] blockByte;
private byte[] blockBytes;
private String toLogin;
private String toBchName;
private Integer toBlockGlobalNumber;
private byte[] toBlockHashe;
private Integer toBlockNumber;
private byte[] toBlockHash;
// новое
private byte[] blockHash;
private byte[] blockSignature;
private Integer editedByBlockGlobalNumber;
private Integer editedByBlockNumber;
private Integer prevLineNumber;
private byte[] prevLineHash;
private Integer thisLineNumber;
public BlockEntry() {}
public BlockEntry(String login,
String bchName,
int blockGlobalNumber,
byte[] blockGlobalPreHashe,
int blockLineIndex,
int blockLineNumber,
byte[] blockLinePreHashe,
int msgType,
int msgSubType,
byte[] blockByte,
String toLogin,
String toBchName,
Integer toBlockGlobalNumber,
byte[] toBlockHashe,
byte[] blockHash,
byte[] blockSignature,
Integer editedByBlockGlobalNumber) {
this.login = login;
this.bchName = bchName;
this.blockGlobalNumber = blockGlobalNumber;
this.blockGlobalPreHashe = blockGlobalPreHashe;
this.blockLineIndex = blockLineIndex;
this.blockLineNumber = blockLineNumber;
this.blockLinePreHashe = blockLinePreHashe;
this.msgType = msgType;
this.msgSubType = msgSubType;
this.blockByte = blockByte;
this.toLogin = toLogin;
this.toBchName = toBchName;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHashe = toBlockHashe;
this.blockHash = blockHash;
this.blockSignature = blockSignature;
this.editedByBlockGlobalNumber = editedByBlockGlobalNumber;
}
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getBchName() { return bchName; }
public void setBchName(String bchName) { this.bchName = bchName; }
public int getBlockGlobalNumber() { return blockGlobalNumber; }
public void setBlockGlobalNumber(int blockGlobalNumber) { this.blockGlobalNumber = blockGlobalNumber; }
public byte[] getBlockGlobalPreHashe() { return blockGlobalPreHashe; }
public void setBlockGlobalPreHashe(byte[] blockGlobalPreHashe) { this.blockGlobalPreHashe = blockGlobalPreHashe; }
public int getBlockLineIndex() { return blockLineIndex; }
public void setBlockLineIndex(int blockLineIndex) { this.blockLineIndex = blockLineIndex; }
public int getBlockLineNumber() { return blockLineNumber; }
public void setBlockLineNumber(int blockLineNumber) { this.blockLineNumber = blockLineNumber; }
public byte[] getBlockLinePreHashe() { return blockLinePreHashe; }
public void setBlockLinePreHashe(byte[] blockLinePreHashe) { this.blockLinePreHashe = blockLinePreHashe; }
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public int getMsgType() { return msgType; }
public void setMsgType(int msgType) { this.msgType = msgType; }
@ -95,8 +64,8 @@ public class BlockEntry {
public int getMsgSubType() { return msgSubType; }
public void setMsgSubType(int msgSubType) { this.msgSubType = msgSubType; }
public byte[] getBlockByte() { return blockByte; }
public void setBlockByte(byte[] blockByte) { this.blockByte = blockByte; }
public byte[] getBlockBytes() { return blockBytes; }
public void setBlockBytes(byte[] blockBytes) { this.blockBytes = blockBytes; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
@ -104,11 +73,11 @@ public class BlockEntry {
public String getToBchName() { return toBchName; }
public void setToBchName(String toBchName) { this.toBchName = toBchName; }
public Integer getToBlockGlobalNumber() { return toBlockGlobalNumber; }
public void setToBlockGlobalNumber(Integer toBlockGlobalNumber) { this.toBlockGlobalNumber = toBlockGlobalNumber; }
public Integer getToBlockNumber() { return toBlockNumber; }
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
public byte[] getToBlockHashe() { return toBlockHashe; }
public void setToBlockHashe(byte[] toBlockHashe) { this.toBlockHashe = toBlockHashe; }
public byte[] getToBlockHash() { return toBlockHash; }
public void setToBlockHash(byte[] toBlockHash) { this.toBlockHash = toBlockHash; }
public byte[] getBlockHash() { return blockHash; }
public void setBlockHash(byte[] blockHash) { this.blockHash = blockHash; }
@ -116,6 +85,15 @@ public class BlockEntry {
public byte[] getBlockSignature() { return blockSignature; }
public void setBlockSignature(byte[] blockSignature) { this.blockSignature = blockSignature; }
public Integer getEditedByBlockGlobalNumber() { return editedByBlockGlobalNumber; }
public void setEditedByBlockGlobalNumber(Integer editedByBlockGlobalNumber) { this.editedByBlockGlobalNumber = editedByBlockGlobalNumber; }
public Integer getEditedByBlockNumber() { return editedByBlockNumber; }
public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; }
public Integer getPrevLineNumber() { return prevLineNumber; }
public void setPrevLineNumber(Integer prevLineNumber) { this.prevLineNumber = prevLineNumber; }
public byte[] getPrevLineHash() { return prevLineHash; }
public void setPrevLineHash(byte[] prevLineHash) { this.prevLineHash = prevLineHash; }
public Integer getThisLineNumber() { return thisLineNumber; }
public void setThisLineNumber(Integer thisLineNumber) { this.thisLineNumber = thisLineNumber; }
}

View File

@ -1,72 +1,38 @@
// =======================
// BlockchainStateEntry.java (НОВАЯ ВЕРСИЯ)
// shine/db/entities/BlockchainStateEntry.java (ИЗМЕНЁННАЯ: убраны line0..7, переименовано last_block_*)
// =======================
package shine.db.entities;
import java.util.Arrays;
import java.util.Base64;
/**
* Агрегатная сущность текущего состояния блокчейна.
* 1 строка = 1 blockchain_name, плюс состояние линий 0..7.
*
* ВАЖНО:
* - hash-поля теперь храним как byte[] и допускаем NULL:
* * NULL = "ещё не было ни одного блока" (genesis и т.п.)
* * не подменяем на new byte[0], чтобы не терять смысл
* - Убраны все поля линий line0..7 (они больше не нужны).
* - Оставляем:
* last_block_number
* last_block_hash
*
* Остальные поля (login, blockchain_key, лимиты) оставлены как в проекте,
* потому что серверу они реально нужны (ключ подписи/лимит файла).
*/
public final class BlockchainStateEntry {
private String blockchainName;
private String login;
private String blockchainKey;
private String blockchainKey; // Base64(32)
private long sizeLimit;
private long fileSizeBytes;
private int lastGlobalNumber;
private byte[] lastGlobalHash; // nullable
private final int[] lastLineNumbers = new int[8];
private final byte[][] lastLineHashes = new byte[8][]; // nullable elements
private int lastBlockNumber; // было last_global_number
private byte[] lastBlockHash; // было last_global_hash (nullable)
private long updatedAtMs;
public BlockchainStateEntry() {
// hashes остаются null по умолчанию (genesis)
}
public BlockchainStateEntry(String blockchainName,
String login,
String blockchainKey,
long sizeLimit,
long fileSizeBytes,
int lastGlobalNumber,
byte[] lastGlobalHash,
int[] lastLineNumbers,
byte[][] lastLineHashes,
long updatedAtMs) {
this.blockchainName = blockchainName;
this.login = login;
this.blockchainKey = blockchainKey;
this.sizeLimit = sizeLimit;
this.fileSizeBytes = fileSizeBytes;
this.lastGlobalNumber = lastGlobalNumber;
this.lastGlobalHash = lastGlobalHash;
if (lastLineNumbers != null) {
if (lastLineNumbers.length != 8) throw new IllegalArgumentException("lastLineNumbers must be len=8");
System.arraycopy(lastLineNumbers, 0, this.lastLineNumbers, 0, 8);
}
if (lastLineHashes != null) {
if (lastLineHashes.length != 8) throw new IllegalArgumentException("lastLineHashes must be len=8");
System.arraycopy(lastLineHashes, 0, this.lastLineHashes, 0, 8);
}
this.updatedAtMs = updatedAtMs;
}
public BlockchainStateEntry() {}
public String getBlockchainName() { return blockchainName; }
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
@ -95,42 +61,12 @@ public final class BlockchainStateEntry {
public long getFileSizeBytes() { return fileSizeBytes; }
public void setFileSizeBytes(long fileSizeBytes) { this.fileSizeBytes = fileSizeBytes; }
public int getLastGlobalNumber() { return lastGlobalNumber; }
public void setLastGlobalNumber(int lastGlobalNumber) { this.lastGlobalNumber = lastGlobalNumber; }
public int getLastBlockNumber() { return lastBlockNumber; }
public void setLastBlockNumber(int lastBlockNumber) { this.lastBlockNumber = lastBlockNumber; }
public byte[] getLastGlobalHash() { return lastGlobalHash; }
public void setLastGlobalHash(byte[] lastGlobalHash) { this.lastGlobalHash = lastGlobalHash; }
public int getLastLineNumber(int line) {
checkLine(line);
return lastLineNumbers[line];
}
public void setLastLineNumber(int line, int value) {
checkLine(line);
lastLineNumbers[line] = value;
}
public byte[] getLastLineHash(int line) {
checkLine(line);
return lastLineHashes[line];
}
public void setLastLineHash(int line, byte[] value) {
checkLine(line);
lastLineHashes[line] = value;
}
public int[] getLastLineNumbersCopy() {
return Arrays.copyOf(lastLineNumbers, 8);
}
public byte[][] getLastLineHashesCopy() {
return Arrays.copyOf(lastLineHashes, 8);
}
public byte[] getLastBlockHash() { return lastBlockHash; }
public void setLastBlockHash(byte[] lastBlockHash) { this.lastBlockHash = lastBlockHash; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
private static void checkLine(int line) {
if (line < 0 || line > 7) throw new IllegalArgumentException("line must be 0..7");
}
}

View File

@ -24,7 +24,7 @@ import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserPa
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request;
// !!! подставь реальные пакеты/имена, как у тебя в проекте:
import server.logic.ws_protocol.JSON.handlers.subscriptions.Net_GetSubscribedChannels_Handler;
//import server.logic.ws_protocol.JSON.handlers.subscriptions.Net_GetSubscribedChannels_Handler;
import server.logic.ws_protocol.JSON.handlers.subscriptions.entyties.Net_GetSubscribedChannels_Request;
import java.util.Map;
@ -48,10 +48,10 @@ public final class JsonHandlerRegistry {
// --- userParams ---
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
Map.entry("GetUserParam", new Net_GetUserParam_Handler()),
Map.entry("ListUserParams", new Net_ListUserParams_Handler()),
Map.entry("ListUserParams", new Net_ListUserParams_Handler())
// --- subscriptions ---
Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler())
// Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler())
);
private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries(

View File

@ -1,40 +1,52 @@
// =======================
// server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java (ИЗМЕНЁННЫЙ под ТЗ)
// =======================
package server.logic.ws_protocol.JSON.handlers.blockchain;
import blockchain.BchBlockEntry;
import blockchain.BchCryptoVerifier;
import blockchain.body.BodyHasLine;
import blockchain.body.BodyHasTarget;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.BlockEntry;
import utils.blockchain.BlockchainNameUtil;
import java.util.Arrays;
import java.util.Base64;
import java.util.concurrent.locks.ReentrantLock;
/**
* Net_AddBlock_Handler единый хэндлер добавления блока (JSON).
*
* Задачи:
* 1) Лочим добавление блоков для конкретного blockchainName (защита от гонок в одном процессе).
* 2) Декодируем блок из Base64 и парсим его структуру.
* 3) Валидируем body (type/version + содержимое).
* 4) Проверяем globalNumber и prevGlobalHash относительно server state.
* 5) Проверяем линии:
* - genesis: global=0, lineIndex=0, lineNumber=0
* - остальные: lineIndex=1..7, lineNumber по счётчику линии
* 6) Проверяем подпись/хэш (Ed25519 над hash32, hash32=sha256(preimage)).
* preimage включает prevLineHash32 (берём из state по lineIndex).
* 7) Пишем в БД+файл через BlockchainWriter (атомарность там).
* Новый порядок валидации (ТЗ):
* 1) Достаём из blockchain_state: last_block_number, last_block_hash
* 2) Проверяем:
* - incoming.blockNumber == last+1
* - incoming.prevHash32 == last_hash (для genesis last_hash = 32 нулей)
* 3) Считаем hash32 = SHA-256(preimage) (preimage = block_bytes без signature64)
* 4) Проверяем подпись Ed25519.verify(hash32, signature64, pubKey)
* 5) Если тип имеет линию:
* - если prevLineNumber != -1:
* достаём hash блока prevLineNumber из blocks
* сравниваем с prevLineHash32 из body
* 6) Сохраняем блок в blocks + обновляем blockchain_state
*
* Важно:
* - Сетевой протокол AddBlock пока оставляем старые поля (globalNumber/prevGlobalHash),
* но внутренняя логика использует НОВЫЙ формат блока.
*/
public final class Net_AddBlock_Handler implements JsonMessageHandler {
@ -56,8 +68,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
try {
AddBlockResult r = addBlock(
blockchainName,
req.getGlobalNumber(),
req.getPrevGlobalHash(),
req.getGlobalNumber(), // старое поле, пока оставляем
req.getPrevGlobalHash(), // старое поле, пока оставляем
req.getBlockBytesB64()
);
@ -73,8 +85,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
resp.setReasonCode(r.reasonCode);
}
resp.setServerLastGlobalNumber(r.serverLastGlobalNumber);
resp.setServerLastGlobalHash(r.serverLastGlobalHashHex);
resp.setServerLastGlobalNumber(r.serverLastBlockNumber);
resp.setServerLastGlobalHash(r.serverLastBlockHashHex);
return resp;
@ -85,313 +97,237 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
private AddBlockResult addBlock(
String blockchainName,
int globalNumber,
String prevGlobalHashHex,
int globalNumberFromReq,
String prevGlobalHashHexFromReq,
String blockBytesB64
) {
if (blockchainName == null || blockchainName.isBlank()) {
log.warn("AddBlock: пустой blockchainName (globalNumber={})", globalNumber);
log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "empty_blockchain_name", 0, "");
}
String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName);
if (login == null || login.isBlank()) {
log.warn("AddBlock: плохой blockchainName='{}' => login не получился (globalNumber={})",
blockchainName, globalNumber);
log.warn("AddBlock: плохой blockchainName='{}' => login не получился (reqGlobalNumber={})",
blockchainName, globalNumberFromReq);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_name", 0, "");
}
// -------------------------------------------------------------------
// 1) state теперь ОБЯЗАТЕЛЕН (и ключ подписи берём из него)
// -------------------------------------------------------------------
// 1) state обязателен
final BlockchainStateEntry st;
try {
st = stateDAO.getByBlockchainName(blockchainName);
} catch (Exception e) {
log.error("AddBlock: ошибка БД при чтении blockchain_state (login={}, blockchainName={}, globalNumber={})",
login, blockchainName, globalNumber, e);
log.error("AddBlock: ошибка БД при чтении blockchain_state (login={}, blockchainName={}, reqGlobalNumber={})",
login, blockchainName, globalNumberFromReq, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error", 0, "");
}
if (st == null) {
log.warn("AddBlock: blockchain_state_not_found (login={}, blockchainName={}, globalNumber={})",
login, blockchainName, globalNumber);
log.warn("AddBlock: blockchain_state_not_found (login={}, blockchainName={}, reqGlobalNumber={})",
login, blockchainName, globalNumberFromReq);
return new AddBlockResult(WireCodes.Status.NOT_FOUND, "blockchain_state_not_found", -1, "");
}
final int serverLastNum = st.getLastGlobalNumber();
final String serverLastHashHex = toHex(nnBytes(st.getLastGlobalHash()));
final int serverLastNum = st.getLastBlockNumber();
final byte[] serverLastHash32 = (serverLastNum < 0)
? new byte[32]
: require32OrThrow(st.getLastBlockHash(), "state.last_block_hash is null/invalid");
// для genesis ожидаем, что state уже в начальном состоянии (-1)
if (globalNumber == 0 && serverLastNum != -1) {
log.warn("AddBlock: genesis_but_state_not_initial (login={}, blockchainName={}, stateLastGlobalNumber={})",
login, blockchainName, serverLastNum);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "genesis_but_state_not_initial", serverLastNum, serverLastHashHex);
}
final String serverLastHashHex = toHex(serverLastHash32);
// следующий global строго
int expectedGlobal = serverLastNum + 1;
if (globalNumber != expectedGlobal) {
log.warn("AddBlock: bad_global_number (login={}, blockchainName={}, пришёл={}, ожидали={}, serverLastNum={}, serverLastHash={})",
login, blockchainName, globalNumber, expectedGlobal, serverLastNum, serverLastHashHex);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_global_number", serverLastNum, serverLastHashHex);
}
// -------------------------------------------------------------------
// 2) Декодируем блок
// -------------------------------------------------------------------
// 2) decode block
final byte[] blockBytes;
try {
blockBytes = decodeBase64(blockBytesB64);
} catch (Exception e) {
log.warn("AddBlock: некорректный base64 блока (login={}, blockchainName={}, globalNumber={})",
login, blockchainName, globalNumber, e);
log.warn("AddBlock: некорректный base64 блока (login={}, blockchainName={}, reqGlobalNumber={})",
login, blockchainName, globalNumberFromReq, e);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_base64", serverLastNum, serverLastHashHex);
}
// -------------------------------------------------------------------
// 3) Ранняя проверка лимита
// -------------------------------------------------------------------
// 3) лимит (оставляем как было)
try {
long oldSize = st.getFileSizeBytes();
long limit = st.getSizeLimit();
long newSize = safeAdd(oldSize, blockBytes.length);
if (limit > 0 && newSize > limit) {
log.warn("AddBlock: limit_exceeded (login={}, blockchainName={}, globalNumber={}, oldSize={}, addLen={}, newSize={}, limit={})",
login, blockchainName, globalNumber, oldSize, blockBytes.length, newSize, limit);
log.warn("AddBlock: limit_exceeded (login={}, blockchainName={}, oldSize={}, addLen={}, newSize={}, limit={})",
login, blockchainName, oldSize, blockBytes.length, newSize, limit);
return new AddBlockResult(413, "limit_exceeded", serverLastNum, serverLastHashHex);
}
} catch (Exception e) {
log.error("AddBlock: limit_check_failed (login={}, blockchainName={}, globalNumber={})",
login, blockchainName, globalNumber, e);
log.error("AddBlock: limit_check_failed (login={}, blockchainName={})", login, blockchainName, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "limit_check_failed", serverLastNum, serverLastHashHex);
}
// -------------------------------------------------------------------
// 4) Парсим блок
// -------------------------------------------------------------------
// 4) parse block
final BchBlockEntry block;
try {
block = new BchBlockEntry(blockBytes);
} catch (Exception e) {
log.warn("AddBlock: не удалось распарсить BchBlockEntry (login={}, blockchainName={}, globalNumber={}, bytesLen={})",
login, blockchainName, globalNumber, blockBytes.length, e);
log.warn("AddBlock: не удалось распарсить BchBlockEntry (login={}, blockchainName={}, bytesLen={})",
login, blockchainName, blockBytes.length, e);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex);
}
// body.check()
try {
block.body.check();
} catch (Exception e) {
log.warn("AddBlock: body.check() не прошёл (login={}, blockchainName={}, globalNumber={}, bodyType={}, bodyVersion={})",
login, blockchainName, globalNumber, safeBodyType(block), safeBodyVersion(block), e);
log.warn("AddBlock: body.check() не прошёл (login={}, blockchainName={}, blockNumber={}, type={}, ver={})",
login, blockchainName, block.blockNumber, (block.type & 0xFFFF), (block.version & 0xFFFF), e);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
}
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", serverLastNum, serverLastHashHex);
// 4.2) запрет дырок: blockNumber строго last+1
int expectedBlockNumber = serverLastNum + 1;
if (block.blockNumber != expectedBlockNumber) {
log.warn("AddBlock: bad_block_number (login={}, blockchainName={}, пришёл={}, ожидали={}, serverLastNum={})",
login, blockchainName, block.blockNumber, expectedBlockNumber, serverLastNum);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_number", serverLastNum, serverLastHashHex);
}
// -------------------------------------------------------------------
// 5) Ключ подписи берём из blockchain_state.blockchainKey (Base64(32))
// -------------------------------------------------------------------
final byte[] solanaKey32;
try {
solanaKey32 = st.getBlockchainKeyBytes();
} catch (Exception e) {
log.warn("AddBlock: bad_blockchain_key_in_state (login={}, blockchainName={}, globalNumber={})",
login, blockchainName, globalNumber, e);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_in_state", serverLastNum, serverLastHashHex);
// (временная совместимость) req.globalNumber должен совпасть с block.blockNumber
if (globalNumberFromReq != block.blockNumber) {
log.warn("AddBlock: req_global_mismatch (login={}, blockchainName={}, reqGlobal={}, blockNumber={})",
login, blockchainName, globalNumberFromReq, block.blockNumber);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "req_global_mismatch", serverLastNum, serverLastHashHex);
}
if (solanaKey32 == null || solanaKey32.length != 32) {
log.warn("AddBlock: bad_blockchain_key_len (login={}, blockchainName={}, globalNumber={}, keyLen={})",
login, blockchainName, globalNumber, (solanaKey32 == null ? -1 : solanaKey32.length));
// 4.3) проверка цепочки по prevHash32
if (!Arrays.equals(block.prevHash32, serverLastHash32)) {
log.warn("AddBlock: bad_prev_hash (login={}, blockchainName={}, blockNumber={}, clientPrev={}, serverPrev={})",
login, blockchainName, block.blockNumber, toHex(block.prevHash32), serverLastHashHex);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_hash", serverLastNum, serverLastHashHex);
}
// 5) pubKey
final byte[] pubKey32 = st.getBlockchainKeyBytes();
if (pubKey32 == null || pubKey32.length != 32) {
log.warn("AddBlock: bad_blockchain_key_len (login={}, blockchainName={}, blockNumber={}, keyLen={})",
login, blockchainName, block.blockNumber, (pubKey32 == null ? -1 : pubKey32.length));
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex);
}
// -------------------------------------------------------------------
// 6) prevGlobalHash сравниваем со state.lastGlobalHash (оба byte[32])
// -------------------------------------------------------------------
final byte[] prevGlobalHash32;
// 6) подпись по hash32(preimage)
boolean sigOk;
try {
prevGlobalHash32 = hexTo32(nn(prevGlobalHashHex)); // "" -> 32 нуля
sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32);
} catch (Exception e) {
log.warn("AddBlock: bad_prev_global_hash_format (login={}, blockchainName={}, globalNumber={}, prevGlobalHashHex='{}')",
login, blockchainName, globalNumber, nn(prevGlobalHashHex), e);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_global_hash_format", serverLastNum, serverLastHashHex);
log.warn("AddBlock: signature_verify_failed (login={}, blockchainName={}, blockNumber={})",
login, blockchainName, block.blockNumber, e);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex);
}
final byte[] serverPrevGlobal32 = serverLastNum < 0 ? new byte[32] : nnBytes(st.getLastGlobalHash());
if (!bytesEq(prevGlobalHash32, serverPrevGlobal32)) {
log.warn("AddBlock: bad_prev_global_hash (login={}, blockchainName={}, globalNumber={}, clientPrev='{}', serverPrev='{}')",
login, blockchainName, globalNumber, nn(prevGlobalHashHex), toHex(serverPrevGlobal32));
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_global_hash", serverLastNum, serverLastHashHex);
if (!sigOk) {
log.warn("AddBlock: bad_signature (login={}, blockchainName={}, blockNumber={})",
login, blockchainName, block.blockNumber);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex);
}
// ===========================
// ЛИНИИ (строго)
// ===========================
int li = block.lineIndex;
int ln = block.lineNumber;
// 7) линейная проверка (только для типов с линией)
Integer prevLineNumber = null;
byte[] prevLineHash32 = null;
Integer thisLineNumber = null;
if (globalNumber == 0) {
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, serverLastHashHex);
}
} else {
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, serverLastHashHex);
}
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, serverLastHashHex);
}
if (block.body instanceof BodyHasLine bl) {
prevLineNumber = bl.prevLineNumber();
prevLineHash32 = bl.prevLineHash32();
thisLineNumber = bl.thisLineNumber();
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, serverLastHashHex);
}
}
final byte[] prevLineHash32;
if (prevLineNumber != null && prevLineNumber != -1) {
try {
prevLineHash32 = computePrevLineHash32(st, li);
byte[] dbPrevHash = blocksDAO.getHashByNumber(blockchainName, prevLineNumber);
if (dbPrevHash == null) {
log.warn("AddBlock: prev_line_block_not_found (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})",
login, blockchainName, block.blockNumber, prevLineNumber);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "prev_line_block_not_found", serverLastNum, serverLastHashHex);
}
if (!Arrays.equals(dbPrevHash, require32OrThrow(prevLineHash32, "prevLineHash32 invalid"))) {
log.warn("AddBlock: bad_prev_line_hash (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})",
login, blockchainName, block.blockNumber, prevLineNumber);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_line_hash", serverLastNum, serverLastHashHex);
}
} 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, serverLastHashHex);
log.error("AddBlock: db_error_prev_line_check (login={}, blockchainName={}, blockNumber={})",
login, blockchainName, block.blockNumber, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error_prev_line_check", serverLastNum, serverLastHashHex);
}
}
}
boolean ok = BchCryptoVerifier.verifyAll(
login,
prevGlobalHash32,
prevLineHash32,
block.getRawBytes(),
block.getSignature64(),
solanaKey32,
block.getHash32()
);
if (!ok) {
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, serverLastHashHex);
}
// write
// 8) сформировать запись и записать (DB + state + файл)
try {
dbWriter.appendBlockAndState(
login,
blockchainName,
prevGlobalHash32,
prevLineHash32,
block,
st
);
BlockEntry be = new BlockEntry();
be.setLogin(login);
be.setBchName(blockchainName);
be.setBlockNumber(block.blockNumber);
be.setMsgType(block.type & 0xFFFF);
be.setMsgSubType(block.subType & 0xFFFF);
be.setBlockBytes(block.toBytes());
be.setBlockHash(block.getHash32());
be.setBlockSignature(block.getSignature64());
// line columns (optional)
be.setPrevLineNumber(prevLineNumber);
be.setPrevLineHash(prevLineHash32);
be.setThisLineNumber(thisLineNumber);
// target columns (optional)
if (block.body instanceof BodyHasTarget t) {
be.setToLogin(t.toLogin());
be.setToBchName(t.toBchName());
be.setToBlockNumber(t.toBlockGlobalNumber());
be.setToBlockHash(t.toBlockHasheBytes());
}
// edit helper (optional): если TEXT_EDIT это "редактирование блока цели"
if ((block.type & 0xFFFF) == 1 && (block.subType & 0xFFFF) == 10 && be.getToBlockNumber() != null) {
be.setEditedByBlockNumber(be.getToBlockNumber());
}
dbWriter.appendBlockAndState(blockchainName, block, st, be);
} catch (Exception e) {
log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, globalNumber={})",
login, blockchainName, globalNumber, e);
log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, blockNumber={})",
login, blockchainName, block.blockNumber, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
}
String newHashHex = toHex(block.getHash32());
log.info("✅ AddBlock ok: login={}, blockchainName={}, globalNumber={}, lineIndex={}, lineNumber={}, newHash={}",
login, blockchainName, globalNumber, li, ln, newHashHex);
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
login, blockchainName, block.blockNumber, newHashHex);
return new AddBlockResult(WireCodes.Status.OK, null, globalNumber, newHashHex);
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
}
/**
* Правило:
* - lineIndex=0: prevLineHash = 32 нулей
* - lineIndex>0:
* - если в этой линии ещё нет блоков (lastLineNumber==0) => prevLineHash = hash(genesis) (line0 hash)
* - иначе => prevLineHash = lastLineHash(lineIndex)
*/
private static byte[] computePrevLineHash32(BlockchainStateEntry st, int lineIndex) {
if (lineIndex == 0) {
return new byte[32];
/* ===================================================================== */
/* ====================== Helpers ====================================== */
/* ===================================================================== */
private static byte[] decodeBase64(String b64) {
if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null");
return Base64.getDecoder().decode(b64);
}
int lastLn = st.getLastLineNumber(lineIndex);
if (lastLn == 0) {
byte[] genesis = nnBytes(st.getLastLineHash(0));
if (genesis.length == 32) return genesis;
byte[] g = nnBytes(st.getLastGlobalHash());
if (g.length == 32) return g;
return new byte[32];
private static long safeAdd(long a, long b) {
long r = a + b;
if (((a ^ r) & (b ^ r)) < 0) throw new ArithmeticException("long overflow");
return r;
}
byte[] last = nnBytes(st.getLastLineHash(lineIndex));
return last.length == 32 ? last : new byte[32];
}
private static final class AddBlockResult {
final int httpStatus;
final String reasonCode;
final int serverLastGlobalNumber;
final String serverLastGlobalHashHex;
AddBlockResult(int httpStatus, String reasonCode, int serverLastGlobalNumber, String serverLastGlobalHashHex) {
this.httpStatus = httpStatus;
this.reasonCode = reasonCode;
this.serverLastGlobalNumber = serverLastGlobalNumber;
this.serverLastGlobalHashHex = serverLastGlobalHashHex;
}
boolean isOk() {
return httpStatus == WireCodes.Status.OK;
}
}
private static String nn(String s) { return s == null ? "" : s; }
private static byte[] nnBytes(byte[] b) { return b == null ? new byte[0] : b; }
private static byte[] decodeBase64(String s) {
if (s == null || s.isBlank()) throw new IllegalArgumentException("empty base64");
return Base64.getDecoder().decode(s);
}
/** hex(64) -> 32 bytes; пустой -> 32 нуля */
private static byte[] hexTo32(String hex) {
if (hex == null || hex.isBlank()) return new byte[32];
String h = hex.trim();
if (h.length() != 64) throw new IllegalArgumentException("hex hash must be 64 chars");
byte[] out = new byte[32];
for (int i = 0; i < 32; i++) {
int hi = Character.digit(h.charAt(i * 2), 16);
int lo = Character.digit(h.charAt(i * 2 + 1), 16);
if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex");
out[i] = (byte)((hi << 4) | lo);
}
return out;
}
private static boolean bytesEq(byte[] a, byte[] b) {
if (a == b) return true;
if (a == null || b == null) return false;
if (a.length != b.length) return false;
int x = 0;
for (int i = 0; i < a.length; i++) x |= (a[i] ^ b[i]);
return x == 0;
private static byte[] require32OrThrow(byte[] b, String msg) {
if (b == null || b.length != 32) throw new IllegalArgumentException(msg);
return b;
}
private static String toHex(byte[] bytes) {
if (bytes == null || bytes.length == 0) return "";
if (bytes == null) return "null";
char[] HEX = "0123456789abcdef".toCharArray();
char[] out = new char[bytes.length * 2];
for (int i = 0; i < bytes.length; i++) {
@ -402,19 +338,19 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new String(out);
}
private static String safeBodyType(BchBlockEntry b) {
try { return String.valueOf(b.body.type()); } catch (Exception e) { return "unknown"; }
private static final class AddBlockResult {
final int httpStatus;
final String reasonCode;
final int serverLastBlockNumber;
final String serverLastBlockHashHex;
AddBlockResult(int httpStatus, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {
this.httpStatus = httpStatus;
this.reasonCode = reasonCode;
this.serverLastBlockNumber = serverLastBlockNumber;
this.serverLastBlockHashHex = serverLastBlockHashHex;
}
private static String safeBodyVersion(BchBlockEntry b) {
try { return String.valueOf(b.body.version()); } catch (Exception e) { return "unknown"; }
}
private static long safeAdd(long x, long y) {
long r = x + y;
if (((x ^ r) & (y ^ r)) < 0) {
throw new IllegalArgumentException("overflow: " + x + " + " + y);
}
return r;
boolean isOk() { return httpStatus == WireCodes.Status.OK; }
}
}

View File

@ -1,342 +1,71 @@
// =======================
// BlockchainWriter.java (НОВАЯ ВЕРСИЯ)
// server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java
// (НОВАЯ ВЕРСИЯ чтобы AddBlock работал с новым blocks/state)
// =======================
package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils;
import blockchain.BchBlockEntry;
import blockchain.body.BodyHasTarget;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.SqliteDbController;
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 shine.db.entities.BlockEntry;
import utils.files.FileStoreUtil;
import shine.log.BlockchainAdminNotifier;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Types;
import java.util.Base64;
/**
* BlockchainWriter единая точка записи:
* 1) создаём новый файл <name>.tmp_bch = oldFileBytes + newBlockBytes
* 2) атомарно фиксируем в БД:
* - blocks (строка блока)
* - blockchain_state (включая новый fileSizeBytes)
* 3) атомарно заменяем файл:
* - удаляем/замещаем старый <name>.bch
* - переименовываем <name>.tmp_bch -> <name>.bch
* BlockchainWriter запись блока в DB + обновление state + запись в файл.
*
* ВАЖНО:
* - Это минимальный рабочий вариант под новый формат.
* - Если у тебя уже есть "атомарность" сложнее (tmp_bch + commit/recovery) можно усилить потом.
*/
public final class BlockchainWriter {
private static final Logger log = LoggerFactory.getLogger(BlockchainWriter.class);
private final SqliteDbController db;
private final BlocksDAO blocksDAO;
private final BlockchainStateDAO stateDAO;
private final FileStoreUtil fs;
private final FileStoreUtil fs = FileStoreUtil.getInstance();
public BlockchainWriter(BlocksDAO blocksDAO, BlockchainStateDAO stateDAO) {
this.db = SqliteDbController.getInstance();
this.blocksDAO = blocksDAO;
this.stateDAO = stateDAO;
this.fs = FileStoreUtil.getInstance();
}
public void appendBlockAndState(
String login,
String blockchainName,
byte[] prevGlobalHash32,
byte[] prevLineHash32,
public void appendBlockAndState(String blockchainName,
BchBlockEntry block,
BlockchainStateEntry stOrNull
) throws SQLException {
BlockchainStateEntry st,
BlockEntry be) throws SQLException {
if (stOrNull == null) {
throw new SQLException("blockchain_state not found for blockchainName=" + blockchainName + " (state обязателен)");
}
long nowMs = System.currentTimeMillis();
verifyMainFileSizeMatchesStateOrAlert(login, blockchainName, block, stOrNull);
// bytes FULL блока (raw+sig+hash)
final byte[] newBlockFullBytes = block.toBytes();
final long oldFileSize = stOrNull.getFileSizeBytes();
final long newFileSize = safeAdd(oldFileSize, newBlockFullBytes.length);
// tmp = old + new
final byte[] tmpBytes;
if (oldFileSize == 0) {
tmpBytes = newBlockFullBytes;
} else {
byte[] oldBytes;
try {
oldBytes = fs.readBlockchain(blockchainName);
} catch (Exception e) {
log.error("Ошибка чтения старого файла блокчейна перед записью tmp (login={}, blockchainName={}, oldFileSize={}, blockNumber={})",
login, blockchainName, oldFileSize, block.recordNumber, e);
throw new SQLException("Cannot read old blockchain file for: " + blockchainName, e);
}
if (oldBytes.length != (int) oldFileSize) {
String msg =
"Несовпадение размера файла блокчейна при чтении: " +
"state ожидал oldFileSize=" + oldFileSize +
", а реально прочитали oldBytes.length=" + oldBytes.length +
" (login=" + login +
", blockchainName=" + blockchainName +
", blockNumber=" + block.recordNumber + ").";
BlockchainAdminNotifier.critical(msg, null);
throw new SQLException(msg);
}
tmpBytes = concat(oldBytes, newBlockFullBytes);
}
try {
fs.writeBlockchainTmp(blockchainName, tmpBytes);
} catch (Exception e) {
log.error("Ошибка записи tmp файла блокчейна (login={}, blockchainName={}, tmpBytesLen={}, oldFileSize={}, newFileSize={}, blockNumber={})",
login, blockchainName, tmpBytes.length, oldFileSize, newFileSize, block.recordNumber, e);
throw new SQLException("Cannot write tmp blockchain file for: " + blockchainName, e);
}
// атомарно БД
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
c.setAutoCommit(false);
boolean committed = false;
try {
insertBlockRow(c, login, blockchainName, prevGlobalHash32, prevLineHash32, block);
appendState(c, blockchainName, block, stOrNull, newFileSize);
// 1) insert block
blocksDAO.insert(c, be);
c.commit();
committed = true;
} catch (Exception e) {
try { c.rollback(); } catch (SQLException ignore) {}
log.error("Ошибка транзакции БД при добавлении блока (rollback выполнен) (login={}, blockchainName={}, blockNumber={}, oldFileSize={}, newFileSize={})",
login, blockchainName, block.recordNumber, oldFileSize, newFileSize, e);
if (e instanceof SQLException se) throw se;
throw new SQLException("appendBlockAndState failed (db tx)", e);
} finally {
try { c.setAutoCommit(oldAutoCommit); } catch (SQLException ignore) {}
}
// после коммита БД атомарно заменяем файл
if (committed) {
try {
fs.atomicReplaceBlockchainFile(blockchainName);
} catch (Exception moveError) {
log.error("БД закоммичена, но атомарная замена файла блокчейна не удалась. tmp оставлен для recovery. (login={}, blockchainName={}, blockNumber={})",
login, blockchainName, block.recordNumber, moveError);
throw new SQLException(
"DB committed but file replace failed; tmp kept for recovery. blockchainName=" + blockchainName,
moveError
);
}
}
}
}
private void verifyMainFileSizeMatchesStateOrAlert(
String login,
String blockchainName,
BchBlockEntry block,
BlockchainStateEntry stOrNull
) throws SQLException {
if (stOrNull == null) return;
long expected = stOrNull.getFileSizeBytes();
if (expected <= 0) return;
String mainFileName = fs.buildBlockchainFileName(blockchainName);
if (!fs.exists(mainFileName)) {
String msg =
"КРИТИЧЕСКАЯ ОШИБКА КОНСИСТЕНТНОСТИ: state ожидает основной файл, но его нет. " +
"login=" + login +
", blockchainName=" + blockchainName +
", expectedSizeFromState=" + expected +
", blockNumber=" + (block != null ? block.recordNumber : -1) + ".";
BlockchainAdminNotifier.critical(msg, null);
throw new SQLException(msg);
}
long real;
try {
real = fs.size(mainFileName);
} catch (Exception e) {
String msg =
"КРИТИЧЕСКАЯ ОШИБКА: не удалось получить размер основного файла блокчейна. " +
"login=" + login +
", blockchainName=" + blockchainName +
", expectedSizeFromState=" + expected +
", blockNumber=" + (block != null ? block.recordNumber : -1) + ".";
BlockchainAdminNotifier.critical(msg, e);
throw new SQLException(msg, e);
}
if (real != expected) {
String msg =
"КРИТИЧЕСКАЯ ОШИБКА КОНСИСТЕНТНОСТИ: размер файла блокчейна НЕ СОВПАДАЕТ с state. " +
"login=" + login +
", blockchainName=" + blockchainName +
", expectedSizeFromState=" + expected +
", realMainFileSize=" + real +
", blockNumber=" + (block != null ? block.recordNumber : -1) + ". " +
"Похоже на внешнее вмешательство/порчу файла. Запись нового блока остановлена.";
BlockchainAdminNotifier.critical(msg, null);
throw new SQLException(msg);
}
}
private void appendState(
Connection c,
String blockchainName,
BchBlockEntry block,
BlockchainStateEntry stOrNull,
long newFileSizeBytes
) throws SQLException {
BlockchainStateEntry st = stOrNull;
if (st == null) {
throw new SQLException("blockchain_state not found for blockchainName=" + blockchainName);
}
// глобальная цепочка
st.setLastGlobalNumber(block.recordNumber);
st.setLastGlobalHash(block.getHash32());
// линия
int li = block.lineIndex;
st.setLastLineNumber(li, block.lineNumber);
st.setLastLineHash(li, block.getHash32());
// file size
st.setFileSizeBytes(newFileSizeBytes);
// timestamp
st.setUpdatedAtMs(System.currentTimeMillis());
// 2) update state
st.setLastBlockNumber(block.blockNumber);
st.setLastBlockHash(block.getHash32());
st.setFileSizeBytes(st.getFileSizeBytes() + block.toBytes().length);
st.setUpdatedAtMs(nowMs);
stateDAO.upsert(c, st);
}
/**
* Вставка/апдейт строки блока в blocks (BLOB-вариант).
*/
private void insertBlockRow(
Connection c,
String login,
String blockchainName,
byte[] prevGlobalHash32,
byte[] prevLineHash32,
BchBlockEntry block
) throws SQLException {
BlockEntry e = new BlockEntry();
e.setLogin(login);
e.setBchName(blockchainName);
e.setBlockGlobalNumber(block.recordNumber);
e.setBlockGlobalPreHashe(prevGlobalHash32);
e.setBlockLineIndex(block.lineIndex);
e.setBlockLineNumber(block.lineNumber);
e.setBlockLinePreHashe(prevLineHash32);
e.setMsgType(block.body.type());
e.setMsgSubType(block.body.subType());
// ВАЖНО: здесь ты кладёшь FULL bytes (raw+sig+hash). Это ок, ты так задумал.
e.setBlockByte(block.toBytes());
// to-поля
e.setToLogin(null);
e.setToBchName(null);
e.setToBlockGlobalNumber(null);
e.setToBlockHashe(null);
if (block.body instanceof BodyHasTarget tf) {
e.setToLogin(tf.toLogin());
e.setToBchName(tf.toBchName());
e.setToBlockGlobalNumber(tf.toBlockGlobalNumber());
e.setToBlockHashe(tf.toBlockHasheBytes());
// если to_login не пришёл, но есть to_bch_name восстановим логин из имени цепочки
if (e.getToLogin() == null && e.getToBchName() != null) {
String toLogin = BlockchainNameUtil.loginFromBlockchainName(e.getToBchName());
if (toLogin != null && !toLogin.isBlank()) {
e.setToLogin(toLogin);
}
c.commit();
} catch (Exception e) {
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) {}
}
}
// новое: хэш и подпись самого блока
e.setBlockHash(block.getHash32());
e.setBlockSignature(block.getSignature64());
// новое: не трогаем (NULL); триггер пометит исходный блок
e.setEditedByBlockGlobalNumber(null);
blocksDAO.upsert(c, e);
}
// -------------------- utils --------------------
private static byte[] concat(byte[] a, byte[] b) {
byte[] out = new byte[a.length + b.length];
System.arraycopy(a, 0, out, 0, a.length);
System.arraycopy(b, 0, out, a.length, b.length);
return out;
}
private static long safeAdd(long x, long y) {
long r = x + y;
if (((x ^ r) & (y ^ r)) < 0) {
throw new IllegalArgumentException("fileSizeBytes overflow: " + x + " + " + y);
}
return r;
}
// Если у тебя где-то ещё остался String-хэш (legacy), используй это в месте парсинга JSON,
// но НЕ в writer. Оставляю тут только на всякий случай для миграции:
@SuppressWarnings("unused")
private static byte[] decodeHashStringLenient(String s) {
if (s == null) return null;
String t = s.trim();
if (t.isEmpty()) return null;
// hex 64
if (t.length() == 64 && t.matches("^[0-9a-fA-F]+$")) {
byte[] out = new byte[32];
for (int i = 0; i < 32; i++) {
int hi = Character.digit(t.charAt(i * 2), 16);
int lo = Character.digit(t.charAt(i * 2 + 1), 16);
out[i] = (byte) ((hi << 4) | lo);
}
return out;
}
// base64 (часто у тебя так)
try {
byte[] b = Base64.getDecoder().decode(t);
return (b != null && b.length == 32) ? b : b;
} catch (IllegalArgumentException ignore) {
return null;
}
// 3) append to file (минимально: просто дописать)
// Если у тебя уже есть логика tmp_bch+atomicReplace можно заменить тут.
String fileName = fs.buildBlockchainFileName(blockchainName);
fs.addDataToFile(fileName, block.toBytes());
}
}

View File

@ -1,147 +1,147 @@
package server.logic.ws_protocol.JSON.handlers.subscriptions;
import blockchain.BchBlockEntry;
import blockchain.body.TextBody;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.subscriptions.entyties.Net_GetSubscribedChannels_Request;
import server.logic.ws_protocol.JSON.handlers.subscriptions.entyties.Net_GetSubscribedChannels_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import shine.db.dao.SubscriptionsDAO;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* Handler: GetSubscribedChannels
*
* Логика:
* - DAO возвращает last publication orig bytes (+ edit bytes если есть)
* - Handler парсит FULL bytes блока:
* timestamp берём из ОРИГИНАЛА (publication)
* текст берём из EDIT (если есть) иначе из оригинала
* - формируем превью первых 50 символов
*/
public class Net_GetSubscribedChannels_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetSubscribedChannels_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetSubscribedChannels_Request req = (Net_GetSubscribedChannels_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректное поле: login"
);
}
// Если хочешь жёстче:
// if (!req.getLogin().matches("^[A-Za-z0-9_]+$")) ...
SubscriptionsDAO dao = SubscriptionsDAO.getInstance();
SqliteDbController db = SqliteDbController.getInstance();
try (Connection c = db.getConnection()) {
List<SubscriptionsDAO.ChannelRow> rows = dao.getSubscribedChannels(c, req.getLogin());
List<Net_GetSubscribedChannels_Response.ChannelInfo> out = new ArrayList<>(rows.size());
for (SubscriptionsDAO.ChannelRow r : rows) {
Net_GetSubscribedChannels_Response.ChannelInfo dto =
new Net_GetSubscribedChannels_Response.ChannelInfo();
dto.setChannelLogin(r.getChannelLogin());
dto.setChannelBchName(r.getChannelBchName());
dto.setPublicationsCount(r.getPublicationsCount());
byte[] pubBytes = r.getLastPublicationBlockBytes();
byte[] editBytes = r.getLastEditBlockBytes();
if (pubBytes == null || pubBytes.length == 0) {
dto.setLastPublicationTimestampSec(null);
dto.setLastTextPreview(null);
out.add(dto);
continue;
}
// 1) timestamp берём из ОРИГИНАЛЬНОЙ публикации
BchBlockEntry pubBlock = new BchBlockEntry(pubBytes);
dto.setLastPublicationTimestampSec(pubBlock.timestamp);
// 2) текст из EDIT (если есть) иначе из оригинала
byte[] actualBytes = (editBytes != null && editBytes.length > 0) ? editBytes : pubBytes;
BchBlockEntry actualBlock = new BchBlockEntry(actualBytes);
if (!(actualBlock.body instanceof TextBody)) {
// Это уже нарушение данных: last publication должен быть текстовым блоком.
throw new IllegalStateException("Last publication is not TextBody: type="
+ (actualBlock.body == null ? "null" : (actualBlock.body.type() & 0xFFFF)));
}
String msg = ((TextBody) actualBlock.body).message;
dto.setLastTextPreview(firstNCharsSafe(msg, 50));
out.add(dto);
}
Net_GetSubscribedChannels_Response resp = new Net_GetSubscribedChannels_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setChannels(out);
return resp;
} catch (SQLException e) {
log.error("❌ DB error GetSubscribedChannels", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"DB_ERROR",
"Ошибка БД"
);
} catch (IllegalArgumentException e) {
// сюда попадёт, например, если BchBlockEntry не смог распарсить block_byte
log.error("❌ Bad block bytes in DB (cannot parse BchBlockEntry)", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"BAD_BLOCK_BYTES",
"В БД обнаружен повреждённый блок"
);
} catch (Exception e) {
log.error("❌ Internal error GetSubscribedChannels", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
"Внутренняя ошибка сервера"
);
}
}
/**
* Берём первые N "символов" безопасно для emoji/суррогатных пар:
* режем по code points.
*/
private static String firstNCharsSafe(String s, int n) {
if (s == null) return null;
if (n <= 0) return "";
int cp = s.codePointCount(0, s.length());
if (cp <= n) return s;
int end = s.offsetByCodePoints(0, n);
return s.substring(0, end);
}
}
//package server.logic.ws_protocol.JSON.handlers.subscriptions;
//
//import blockchain.BchBlockEntry;
//import blockchain.body.TextBody;
//import org.slf4j.Logger;
//import org.slf4j.LoggerFactory;
//import server.logic.ws_protocol.JSON.ConnectionContext;
//import server.logic.ws_protocol.JSON.entyties.Net_Request;
//import server.logic.ws_protocol.JSON.entyties.Net_Response;
//import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
//import server.logic.ws_protocol.JSON.handlers.subscriptions.entyties.Net_GetSubscribedChannels_Request;
//import server.logic.ws_protocol.JSON.handlers.subscriptions.entyties.Net_GetSubscribedChannels_Response;
//import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
//import server.logic.ws_protocol.WireCodes;
//import shine.db.SqliteDbController;
//import shine.db.dao.SubscriptionsDAO;
//
//import java.sql.Connection;
//import java.sql.SQLException;
//import java.util.ArrayList;
//import java.util.List;
//
///**
// * Handler: GetSubscribedChannels
// *
// * Логика:
// * - DAO возвращает last publication orig bytes (+ edit bytes если есть)
// * - Handler парсит FULL bytes блока:
// * timestamp берём из ОРИГИНАЛА (publication)
// * текст берём из EDIT (если есть) иначе из оригинала
// * - формируем превью первых 50 символов
// */
//public class Net_GetSubscribedChannels_Handler implements JsonMessageHandler {
//
// private static final Logger log = LoggerFactory.getLogger(Net_GetSubscribedChannels_Handler.class);
//
// @Override
// public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
// Net_GetSubscribedChannels_Request req = (Net_GetSubscribedChannels_Request) baseRequest;
//
// if (req.getLogin() == null || req.getLogin().isBlank()) {
// return NetExceptionResponseFactory.error(
// req,
// WireCodes.Status.BAD_REQUEST,
// "BAD_FIELDS",
// "Некорректное поле: login"
// );
// }
//
// // Если хочешь жёстче:
// // if (!req.getLogin().matches("^[A-Za-z0-9_]+$")) ...
//
// SubscriptionsDAO dao = SubscriptionsDAO.getInstance();
// SqliteDbController db = SqliteDbController.getInstance();
//
// try (Connection c = db.getConnection()) {
//
// List<SubscriptionsDAO.ChannelRow> rows = dao.getSubscribedChannels(c, req.getLogin());
// List<Net_GetSubscribedChannels_Response.ChannelInfo> out = new ArrayList<>(rows.size());
//
// for (SubscriptionsDAO.ChannelRow r : rows) {
// Net_GetSubscribedChannels_Response.ChannelInfo dto =
// new Net_GetSubscribedChannels_Response.ChannelInfo();
//
// dto.setChannelLogin(r.getChannelLogin());
// dto.setChannelBchName(r.getChannelBchName());
// dto.setPublicationsCount(r.getPublicationsCount());
//
// byte[] pubBytes = r.getLastPublicationBlockBytes();
// byte[] editBytes = r.getLastEditBlockBytes();
//
// if (pubBytes == null || pubBytes.length == 0) {
// dto.setLastPublicationTimestampSec(null);
// dto.setLastTextPreview(null);
// out.add(dto);
// continue;
// }
//
// // 1) timestamp берём из ОРИГИНАЛЬНОЙ публикации
// BchBlockEntry pubBlock = new BchBlockEntry(pubBytes);
// dto.setLastPublicationTimestampSec(pubBlock.timestamp);
//
// // 2) текст из EDIT (если есть) иначе из оригинала
// byte[] actualBytes = (editBytes != null && editBytes.length > 0) ? editBytes : pubBytes;
// BchBlockEntry actualBlock = new BchBlockEntry(actualBytes);
//
// if (!(actualBlock.body instanceof TextBody)) {
// // Это уже нарушение данных: last publication должен быть текстовым блоком.
// throw new IllegalStateException("Last publication is not TextBody: type="
// + (actualBlock.body == null ? "null" : (actualBlock.body.type() & 0xFFFF)));
// }
//
// String msg = ((TextBody) actualBlock.body).message;
// dto.setLastTextPreview(firstNCharsSafe(msg, 50));
//
// out.add(dto);
// }
//
// Net_GetSubscribedChannels_Response resp = new Net_GetSubscribedChannels_Response();
// resp.setOp(req.getOp());
// resp.setRequestId(req.getRequestId());
// resp.setStatus(WireCodes.Status.OK);
// resp.setChannels(out);
//
// return resp;
//
// } catch (SQLException e) {
// log.error("❌ DB error GetSubscribedChannels", e);
// return NetExceptionResponseFactory.error(
// req,
// WireCodes.Status.SERVER_DATA_ERROR,
// "DB_ERROR",
// "Ошибка БД"
// );
// } catch (IllegalArgumentException e) {
// // сюда попадёт, например, если BchBlockEntry не смог распарсить block_byte
// log.error("❌ Bad block bytes in DB (cannot parse BchBlockEntry)", e);
// return NetExceptionResponseFactory.error(
// req,
// WireCodes.Status.SERVER_DATA_ERROR,
// "BAD_BLOCK_BYTES",
// "В БД обнаружен повреждённый блок"
// );
// } catch (Exception e) {
// log.error("❌ Internal error GetSubscribedChannels", e);
// return NetExceptionResponseFactory.error(
// req,
// WireCodes.Status.INTERNAL_ERROR,
// "INTERNAL_ERROR",
// "Внутренняя ошибка сервера"
// );
// }
// }
//
// /**
// * Берём первые N "символов" безопасно для emoji/суррогатных пар:
// * режем по code points.
// */
// private static String firstNCharsSafe(String s, int n) {
// if (s == null) return null;
// if (n <= 0) return "";
// int cp = s.codePointCount(0, s.length());
// if (cp <= n) return s;
// int end = s.offsetByCodePoints(0, n);
// return s.substring(0, end);
// }
//}

View File

@ -113,8 +113,8 @@ public class Net_AddUser_Handler implements JsonMessageHandler {
st.setBlockchainName(req.getBlockchainName());
st.setLogin(req.getLogin());
st.setBlockchainKey(req.getBlockchainKey()); // Base64(32)
st.setLastGlobalNumber(-1);
st.setLastGlobalHash(new byte[32]);
st.setLastBlockNumber(-1);
st.setLastBlockHash(new byte[32]);
st.setFileSizeBytes(0);
st.setSizeLimit(limit);
st.setUpdatedAtMs(System.currentTimeMillis());

View File

@ -1,16 +1,13 @@
package test.it.blockchain;
import blockchain.BchBlockEntry;
import blockchain.BchCryptoVerifier;
import blockchain.body.BodyRecord;
import test.it.utils.json.JsonParsers;
import blockchain.body.*;
import test.it.utils.TestConfig;
import test.it.utils.TestIds;
import test.it.utils.json.JsonParsers;
import test.it.utils.log.TestLog;
import test.it.utils.ws.WsSession;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.time.Duration;
import java.util.Base64;
@ -18,16 +15,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* AddBlockSender отправка AddBlock поверх одного WsSession:
* - берёт номера/prev-hash из ChainState
* - строит raw/hash/signature
* - отправляет AddBlock
* - проверяет serverLastGlobalHash == localHash
* - обновляет ChainState
* AddBlockSender под новый формат BchBlockEntry:
* - block хранит только preimage + signature
* - hash32 вычисляется как sha256(preimage)
* - signature = Ed25519.sign(hash32)
*/
public final class AddBlockSender {
private static final byte[] ZERO32 = new byte[32];
private static final String ZERO64 = "0".repeat(64);
private final WsSession ws;
@ -52,69 +46,89 @@ public final class AddBlockSender {
public void send(BodyRecord body, Duration timeout) {
if (body == null) throw new IllegalArgumentException("body == null");
short lineIndex = body.expectedLineIndex();
body.check();
if (lineIndex == 0) {
if (state.globalLastNumber() != -1) throw new IllegalStateException("HEADER должен быть первым: globalLastNumber уже " + state.globalLastNumber());
boolean isHeader = (body instanceof HeaderBody);
if (isHeader) {
if (state.lastBlockNumber() != -1) {
throw new IllegalStateException("HEADER должен быть первым: lastBlockNumber уже " + state.lastBlockNumber());
}
} else {
if (!state.hasHeader()) throw new IllegalStateException("Нельзя слать line=" + lineIndex + " до HEADER (нет headerHash32)");
if (!state.hasHeader()) {
throw new IllegalStateException("Нельзя слать блоки до HEADER (нет headerHash32)");
}
}
int globalNumber = state.nextGlobalNumber();
int lineNumber = state.nextLineNumber(lineIndex);
int blockNumber = state.nextBlockNumber();
byte[] prevHash32 = state.prevHash32ForNext();
long tsSec = System.currentTimeMillis() / 1000L;
byte[] prevGlobalHash32 = (lineIndex == 0) ? ZERO32 : state.prevGlobalHash32ForNext(lineIndex);
byte[] prevLineHash32 = (lineIndex == 0) ? ZERO32 : state.prevLineHash32ForNext(lineIndex);
short type = typeOf(body);
short subType = subTypeOf(body);
short version = versionOf(body);
long ts = System.currentTimeMillis() / 1000L;
byte[] bodyBytes = body.toBytes();
int recordSize = BchBlockEntry.RAW_HEADER_SIZE + bodyBytes.length;
byte[] rawBytes = ByteBuffer.allocate(recordSize)
.order(ByteOrder.BIG_ENDIAN)
.putInt(recordSize)
.putInt(globalNumber)
.putLong(ts)
.putShort(lineIndex)
.putInt(lineNumber)
.put(bodyBytes)
.array();
byte[] preimage = BchCryptoVerifier.buildPreimage(login, prevGlobalHash32, prevLineHash32, rawBytes);
byte[] hash32 = BchCryptoVerifier.sha256(preimage);
// preimage -> hash32 -> signature
byte[] preimage = buildPreimage(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes);
byte[] hash32 = blockchain.BchCryptoVerifier.sha256(preimage);
byte[] signature64 = utils.crypto.Ed25519Util.sign(hash32, loginPrivKey);
BchBlockEntry entry = new BchBlockEntry(globalNumber, ts, lineIndex, lineNumber, bodyBytes, signature64, hash32);
BchBlockEntry entry = new BchBlockEntry(
prevHash32,
blockNumber,
tsSec,
type,
subType,
version,
bodyBytes,
signature64
);
String prevGlobalHashHex = (globalNumber == 0) ? ZERO64 : state.globalLastHashHex();
String prevHashHexForReq = (blockNumber == 0) ? ZERO64 : state.lastBlockHashHex();
String reqJson = buildAddBlockJson(blockchainName, globalNumber, prevGlobalHashHex, base64(entry.toBytes()));
String op = "AddBlock(user=" + login + ", global=" + globalNumber + ", line=" + lineIndex + ", lineNum=" + lineNumber + ")";
String reqJson = buildAddBlockJson(blockchainName, blockNumber, prevHashHexForReq, base64(entry.toBytes()));
String op = "AddBlock(user=" + login + ", block=" + blockNumber + ", type=" + (type & 0xFFFF) + ", sub=" + (subType & 0xFFFF) + ")";
String resp = ws.call(op, reqJson, timeout);
assert200(op, resp);
String serverLastGlobalHash = JsonMini.extractPayloadString(resp, "serverLastGlobalHash");
assertNotNull(serverLastGlobalHash, op + ": payload.serverLastGlobalHash must not be null");
assertEquals(64, serverLastGlobalHash.trim().length(), op + ": serverLastGlobalHash must be 64 hex chars");
String serverLastHash = JsonMini.extractPayloadString(resp, "serverLastBlockHash");
if (serverLastHash == null) {
// на случай старого имени, но по твоей просьбе мы на это больше не опираемся
serverLastHash = JsonMini.extractPayloadString(resp, "serverLastGlobalHash");
}
String localHashHex = bytesToHex64(hash32);
assertNotNull(serverLastHash, op + ": payload.serverLastBlockHash must not be null");
assertEquals(64, serverLastHash.trim().length(), op + ": serverLastBlockHash must be 64 hex chars");
String localHashHex = bytesToHex64(entry.getHash32());
if (TestConfig.DEBUG()) {
TestLog.info(op + ": localHash=" + localHashHex);
TestLog.info(op + ": serverLastGlobalHash=" + serverLastGlobalHash);
TestLog.info(op + ": serverLastBlockHash=" + serverLastHash);
}
assertEquals(localHashHex, serverLastGlobalHash, op + ": serverLastGlobalHash must match local hash");
assertEquals(localHashHex, serverLastHash, op + ": serverLastBlockHash must match local hash");
state.applyAppendedBlock(globalNumber, lineIndex, lineNumber, hash32);
state.applyAppendedBlock(blockNumber, entry.getHash32(), isHeader, type);
// если это line-body обновим thisLineNumber в state (для nextLine())
if (body instanceof BodyHasLine hl) {
short lineIndex = lineIndexByType(type);
if (lineIndex != -1) {
state.applyThisLineNumber(lineIndex, hl.thisLineNumber());
}
}
if (TestConfig.DEBUG()) TestLog.info(op + ": state updated");
}
private static String buildAddBlockJson(String blockchainName, int globalNumber, String prevGlobalHashHex, String blockBytesB64) {
// ---------- request JSON ----------
private static String buildAddBlockJson(String blockchainName, int blockNumber, String prevBlockHashHex, String blockBytesB64) {
String requestId = TestIds.next("addblock");
return """
{
@ -122,12 +136,12 @@ public final class AddBlockSender {
"requestId": "%s",
"payload": {
"blockchainName": "%s",
"globalNumber": %d,
"prevGlobalHash": "%s",
"blockNumber": %d,
"prevBlockHash": "%s",
"blockBytesB64": "%s"
}
}
""".formatted(requestId, blockchainName, globalNumber, prevGlobalHashHex, blockBytesB64);
""".formatted(requestId, blockchainName, blockNumber, prevBlockHashHex, blockBytesB64);
}
private static void assert200(String op, String resp) {
@ -150,4 +164,70 @@ public final class AddBlockSender {
}
return new String(out);
}
// ---------- header extraction from body ----------
private static short typeOf(BodyRecord body) {
if (body instanceof HeaderBody) return HeaderBody.TYPE;
if (body instanceof TextBody) return TextBody.TYPE;
if (body instanceof ReactionBody) return ReactionBody.TYPE;
if (body instanceof ConnectionBody) return ConnectionBody.TYPE;
if (body instanceof UserParamBody) return UserParamBody.TYPE;
throw new IllegalArgumentException("Unknown body class: " + body.getClass());
}
private static short subTypeOf(BodyRecord body) {
if (body instanceof HeaderBody hb) return hb.subType;
if (body instanceof TextBody tb) return tb.subType;
if (body instanceof ReactionBody rb) return rb.subType;
if (body instanceof ConnectionBody cb) return cb.subType;
if (body instanceof UserParamBody ub) return ub.subType;
throw new IllegalArgumentException("Unknown body class: " + body.getClass());
}
private static short versionOf(BodyRecord body) {
if (body instanceof HeaderBody hb) return hb.version;
if (body instanceof TextBody tb) return tb.version;
if (body instanceof ReactionBody rb) return rb.version;
if (body instanceof ConnectionBody cb) return cb.version;
if (body instanceof UserParamBody ub) return ub.version;
throw new IllegalArgumentException("Unknown body class: " + body.getClass());
}
// ---------- preimage builder (строго по BchBlockEntry) ----------
private static byte[] buildPreimage(byte[] prevHash32,
int blockNumber,
long tsSec,
short type,
short subType,
short version,
byte[] bodyBytes) {
int blockSize = BchBlockEntry.RAW_HEADER_SIZE + (bodyBytes == null ? 0 : bodyBytes.length);
java.nio.ByteBuffer bb = java.nio.ByteBuffer.allocate(blockSize).order(java.nio.ByteOrder.BIG_ENDIAN);
bb.put(prevHash32);
bb.putInt(blockSize);
bb.putInt(blockNumber);
bb.putLong(tsSec);
bb.putShort(type);
bb.putShort(subType);
bb.putShort(version);
if (bodyBytes != null) bb.put(bodyBytes);
return bb.array();
}
private static short lineIndexByType(short type) {
int t = type & 0xFFFF;
return switch (t) {
case 0 -> blockchain.LineIndex.HEADER;
case 1 -> blockchain.LineIndex.TEXT;
case 3 -> blockchain.LineIndex.CONNECTION;
case 4 -> blockchain.LineIndex.USER_PARAM;
default -> (short) -1;
};
}
}

View File

@ -1,17 +1,25 @@
package test.it.blockchain;
import blockchain.LineIndex;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
/**
* ChainState только состояние цепочки (номера/хэши).
* ChainState состояние цепочки + состояние линий (только тех, где они нужны):
*
* Хранит:
* - last globalNumber / last globalHash
* - last lineNum / last lineHash по каждой линии
* - hash32 нулевого блока (headerHash32) нужен как prevLineHash для первого блока каждой линии
* - map globalNumber -> hash32 (для ссылок reply/reaction на старые блоки)
* Глобальная цепочка:
* - lastBlockNumber / lastBlockHashHex
* - map blockNumber -> hash32 (для ссылок reply/edit/reaction)
*
* Линии (по ТЗ нужны):
* - TEXT (1)
* - CONNECTION (3)
* - USER_PARAM (4)
*
* prevLineNumber по ТЗ это GLOBAL blockNumber предыдущего блока линии.
* thisLineNumber внутренний номер линии (мы ведём локально: 1,2,3...)
*/
public final class ChainState {
@ -20,134 +28,157 @@ public final class ChainState {
private static final byte[] ZERO32 = new byte[32];
private static final String ZERO64 = "0".repeat(64);
private final int[] lineLastNumber = new int[LINES_MAX];
private final String[] lineLastHashHex = new String[LINES_MAX];
private int globalLastNumber = -1;
private String globalLastHashHex = ZERO64;
// global chain
private int lastBlockNumber = -1;
private String lastBlockHashHex = ZERO64;
// header (block#0)
private byte[] headerHash32 = null;
// Для удобства тестов: чтобы можно было делать reply/like на любой уже отправленный globalNumber
private final Map<Integer, byte[]> globalHash32ByNumber = new HashMap<>();
// per-line state (только для LineIndex.TEXT/CONNECTION/USER_PARAM)
private final int[] lineLastGlobalNumber = new int[LINES_MAX]; // последний GLOBAL номер блока в линии
private final String[] lineLastHashHex = new String[LINES_MAX]; // hash последнего блока линии
private final int[] lineLastThisLineNumber = new int[LINES_MAX]; // последний thisLineNumber (внутренний)
private final Map<Integer, byte[]> hash32ByNumber = new HashMap<>();
public ChainState() {
Arrays.fill(lineLastGlobalNumber, -1);
Arrays.fill(lineLastHashHex, "");
// lineLastNumber по умолчанию = 0
Arrays.fill(lineLastThisLineNumber, 0);
}
// -------------------- getters --------------------
// -------------------- global getters --------------------
public int globalLastNumber() { return globalLastNumber; }
public String globalLastHashHex() { return globalLastHashHex; }
public int lastBlockNumber() { return lastBlockNumber; }
public String lastBlockHashHex() { return lastBlockHashHex; }
public int lineLastNumber(short line) { return lineLastNumber[line]; }
public String lineLastHashHex(short line) { return lineLastHashHex[line]; }
public boolean hasHeader() {
return headerHash32 != null && headerHash32.length == 32 && lastBlockNumber >= 0;
}
public byte[] headerHash32() { return headerHash32 == null ? null : headerHash32.clone(); }
public int nextBlockNumber() {
return lastBlockNumber + 1;
}
public byte[] getGlobalHash32(int globalNumber) {
byte[] h = globalHash32ByNumber.get(globalNumber);
public byte[] prevHash32ForNext() {
if (lastBlockNumber < 0) return ZERO32;
return hexToBytes32(lastBlockHashHex);
}
public byte[] headerHash32() {
return headerHash32 == null ? null : headerHash32.clone();
}
public byte[] getHash32(int blockNumber) {
byte[] h = hash32ByNumber.get(blockNumber);
return h == null ? null : h.clone();
}
// -------------------- state helpers --------------------
// -------------------- line helpers --------------------
public boolean hasHeader() {
return headerHash32 != null && headerHash32.length == 32 && globalLastNumber >= 0;
public static final class NextLine {
public final int prevLineNumber; // GLOBAL blockNumber
public final byte[] prevLineHash32; // 32 bytes
public final int thisLineNumber; // внутр. номер линии
public NextLine(int prevLineNumber, byte[] prevLineHash32, int thisLineNumber) {
this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? null : prevLineHash32.clone());
this.thisLineNumber = thisLineNumber;
}
}
/** Следующий globalNumber. */
public int nextGlobalNumber() {
return globalLastNumber + 1;
}
/** Следующий lineNumber: для line>0 — last+1. Для line0 — всегда 0 (header). */
public int nextLineNumber(short lineIndex) {
/** Следующие line-поля для указанной линии (только TEXT/CONNECTION/USER_PARAM). */
public NextLine nextLine(short lineIndex) {
checkLine(lineIndex);
if (lineIndex == 0) return 0;
return lineLastNumber[lineIndex] + 1;
if (!isLineUsed(lineIndex)) {
throw new IllegalArgumentException("Line " + lineIndex + " не используется для BodyHasLine по ТЗ");
}
if (!hasHeader()) {
throw new IllegalStateException("Нельзя формировать line-поля до HEADER (нет headerHash32)");
}
/** prevGlobalHash32: для header это ZERO32, иначе hash последнего глобального блока. */
public byte[] prevGlobalHash32ForNext(short nextLineIndex) {
// Для genesis/header prevGlobalHash = ZERO32
if (globalLastNumber < 0) return ZERO32;
return hexToBytes32(globalLastHashHex);
}
int lastGlobal = lineLastGlobalNumber[lineIndex];
int lastThis = lineLastThisLineNumber[lineIndex];
/**
* prevLineHash32 по твоему правилу:
* - для line0 (header) ZERO32
* - для первого блока линии (lineLastNumber[line]==0) hash нулевого блока (headerHash32)
* - иначе hash последнего блока этой линии
*/
public byte[] prevLineHash32ForNext(short lineIndex) {
checkLine(lineIndex);
if (lineIndex == 0) return ZERO32;
if (lineLastNumber[lineIndex] == 0) {
if (headerHash32 == null) {
throw new IllegalStateException("headerHash32 is not set but required for first block of line " + lineIndex);
}
return headerHash32.clone();
if (lastGlobal == -1) {
// первый блок линии ссылается на HEADER (block#0)
return new NextLine(0, headerHash32.clone(), 1);
}
String lastHex = lineLastHashHex[lineIndex];
if (lastHex == null || lastHex.isBlank()) {
throw new IllegalStateException("lineLastHashHex[" + lineIndex + "] is blank but lineLastNumber>0");
}
return hexToBytes32(lastHex);
throw new IllegalStateException("lineLastHashHex[" + lineIndex + "] пуст, но lastGlobal!=-1");
}
/**
* Применить факт успешного добавления блока:
* - обновить global last
* - обновить line last
* - сохранить globalNumber->hash32
* - если это header: сохранить headerHash32
*/
public void applyAppendedBlock(int globalNumber,
short lineIndex,
int lineNumber,
byte[] hash32) {
return new NextLine(lastGlobal, hexToBytes32(lastHex), lastThis + 1);
}
// -------------------- apply --------------------
public void applyAppendedBlock(int blockNumber, byte[] hash32, boolean isHeader, short type) {
if (hash32 == null || hash32.length != 32) {
throw new IllegalArgumentException("hash32 must be 32 bytes");
}
// базовые ожидания по номерам (для тестов строго)
if (globalNumber != globalLastNumber + 1) {
throw new IllegalStateException("globalNumber sequence broken: expected=" + (globalLastNumber + 1) + " got=" + globalNumber);
if (blockNumber != lastBlockNumber + 1) {
throw new IllegalStateException("blockNumber sequence broken: expected=" + (lastBlockNumber + 1) + " got=" + blockNumber);
}
checkLine(lineIndex);
if (lineIndex == 0) {
if (globalNumber != 0 || lineNumber != 0) {
throw new IllegalStateException("Header must be global=0 line=0 lineNum=0");
}
if (isHeader) {
if (blockNumber != 0) throw new IllegalStateException("HEADER must be blockNumber=0");
headerHash32 = hash32.clone();
} else {
int expectedLineNum = lineLastNumber[lineIndex] + 1;
if (lineNumber != expectedLineNum) {
throw new IllegalStateException("lineNumber sequence broken for line=" + lineIndex +
": expected=" + expectedLineNum + " got=" + lineNumber);
}
if (blockNumber == 0) throw new IllegalStateException("Non-header block can't be blockNumber=0");
if (headerHash32 == null) throw new IllegalStateException("Header must be sent before non-header blocks");
}
String hex64 = bytesToHex64(hash32);
globalLastNumber = globalNumber;
globalLastHashHex = hex64;
lastBlockNumber = blockNumber;
lastBlockHashHex = hex64;
lineLastNumber[lineIndex] = lineNumber;
hash32ByNumber.put(blockNumber, hash32.clone());
// обновляем line-state только для линий, которые "надо" по ТЗ
short lineIndex = lineIndexByType(type);
if (lineIndex != -1 && isLineUsed(lineIndex)) {
lineLastGlobalNumber[lineIndex] = blockNumber;
lineLastHashHex[lineIndex] = hex64;
globalHash32ByNumber.put(globalNumber, hash32.clone());
// thisLineNumber мы берём из тела, но здесь его нет.
// Поэтому thisLineNumber должен обновляться там, где формируются тела (в тестах),
// либо AddBlockSender может прокинуть его отдельно.
// Чтобы не дублировать контракт здесь оставляем как есть.
}
}
// -------------------- utils --------------------
/** В тестах удобно явно обновлять thisLineNumber после успешной отправки line-body. */
public void applyThisLineNumber(short lineIndex, int thisLineNumber) {
checkLine(lineIndex);
if (!isLineUsed(lineIndex)) return;
lineLastThisLineNumber[lineIndex] = thisLineNumber;
}
// -------------------- mapping --------------------
/** По type блока определяем lineIndex. Reaction line по твоему ТЗ "не надо". */
private static short lineIndexByType(short type) {
int t = type & 0xFFFF;
return switch (t) {
case 0 -> LineIndex.HEADER;
case 1 -> LineIndex.TEXT;
case 3 -> LineIndex.CONNECTION;
case 4 -> LineIndex.USER_PARAM;
default -> (short) -1; // reaction/unknown => line state not used
};
}
private static boolean isLineUsed(short lineIndex) {
return lineIndex == LineIndex.TEXT
|| lineIndex == LineIndex.CONNECTION
|| lineIndex == LineIndex.USER_PARAM;
}
private static void checkLine(short lineIndex) {
if (lineIndex < 0 || lineIndex >= LINES_MAX) {
@ -155,6 +186,8 @@ public final class ChainState {
}
}
// -------------------- utils --------------------
private static byte[] hexToBytes32(String hex) {
if (hex == null) throw new IllegalArgumentException("hex is null");
String s = hex.trim();

View File

@ -1,10 +1,8 @@
package test.it.cases;
import blockchain.body.ConnectionBody;
import blockchain.body.HeaderBody;
import blockchain.body.ReactionBody;
import blockchain.body.TextBody;
import blockchain.body.UserParamBody;
import blockchain.LineIndex;
import blockchain.body.*;
import shine.db.MsgSubType;
import test.it.blockchain.AddBlockSender;
import test.it.blockchain.ChainState;
import test.it.utils.TestConfig;
@ -17,11 +15,7 @@ import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
/**
* IT_03_AddBlock_NoAuth
*
* ВАЖНО:
* - пользователей НЕ создаём (их создаёт IT_01)
* - ключи берём только из TestConfig по login
* IT_03_AddBlock_NoAuth обновлён под новый формат блоков (ТЗ).
*/
public class IT_03_AddBlock_NoAuth {
@ -63,30 +57,88 @@ public class IT_03_AddBlock_NoAuth {
sender1.send(new HeaderBody(u1), t);
assertTrue(st1.hasHeader());
sender1.send(new TextBody(TextBody.SUB_NEW, "Hello #1 (NEW) from IT_03 test"), t);
sender1.send(new TextBody(TextBody.SUB_NEW, "Hello #2 (NEW) from IT_03 test"), t);
sender1.send(new TextBody(TextBody.SUB_NEW, "Hello #3 (NEW) from IT_03 test"), t);
// TEXT_NEW x3 (с line)
{
var ln = st1.nextLine(LineIndex.TEXT);
sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.TEXT_NEW,
"Hello #1 (NEW) from IT_03 test",
null, null, null
), t);
}
{
var ln = st1.nextLine(LineIndex.TEXT);
sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.TEXT_NEW,
"Hello #2 (NEW) from IT_03 test",
null, null, null
), t);
}
{
var ln = st1.nextLine(LineIndex.TEXT);
sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.TEXT_NEW,
"Hello #3 (NEW) from IT_03 test",
null, null, null
), t);
}
byte[] text1Hash = st1.getGlobalHash32(1);
byte[] text2Hash = st1.getGlobalHash32(2);
byte[] text3Hash = st1.getGlobalHash32(3);
byte[] text1Hash = st1.getHash32(1);
byte[] text2Hash = st1.getHash32(2);
byte[] text3Hash = st1.getHash32(3);
assertNotNull(text1Hash);
assertNotNull(text2Hash);
assertNotNull(text3Hash);
sender1.send(new TextBody(TextBody.SUB_REPLY, "Reply to TEXT#1", bch1, 1, text1Hash), t);
sender1.send(new TextBody(TextBody.SUB_REPLY, "Reply to TEXT#3", bch1, 3, text3Hash), t);
// TEXT_REPLY x2 (с line + target)
{
var ln = st1.nextLine(LineIndex.TEXT);
sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.TEXT_REPLY,
"Reply to TEXT#1",
bch1, 1, text1Hash
), t);
}
{
var ln = st1.nextLine(LineIndex.TEXT);
sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.TEXT_REPLY,
"Reply to TEXT#3",
bch1, 3, text3Hash
), t);
}
sender1.send(new ReactionBody(ReactionBody.SUB_LIKE, bch1, 1, text1Hash), t);
sender1.send(new ReactionBody(ReactionBody.SUB_LIKE, bch1, 2, text2Hash), t);
// REACTION_LIKE x2 (без line)
sender1.send(new ReactionBody(bch1, 1, text1Hash), t);
sender1.send(new ReactionBody(bch1, 2, text2Hash), t);
sender1.send(new TextBody(TextBody.SUB_EDIT, "Hello #2 (EDIT#1) from IT_03 test", bch1, 2, text2Hash), t);
sender1.send(new TextBody(TextBody.SUB_EDIT, "Hello #2 (EDIT#2) from IT_03 test", bch1, 2, text2Hash), t);
sender1.send(new TextBody(TextBody.SUB_EDIT, "Hello #3 (EDIT#1) from IT_03 test", bch1, 3, text3Hash), t);
// TEXT_EDIT x3 (с line + target)
{
var ln = st1.nextLine(LineIndex.TEXT);
sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.TEXT_EDIT,
"Hello #2 (EDIT#1) from IT_03 test",
bch1, 2, text2Hash
), t);
}
{
var ln = st1.nextLine(LineIndex.TEXT);
sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.TEXT_EDIT,
"Hello #2 (EDIT#2) from IT_03 test",
bch1, 2, text2Hash
), t);
}
{
var ln = st1.nextLine(LineIndex.TEXT);
sender1.send(new TextBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.TEXT_EDIT,
"Hello #3 (EDIT#1) from IT_03 test",
bch1, 3, text3Hash
), t);
}
assertEquals(10, st1.globalLastNumber(), "USER1: globalLastNumber должен быть 10 (11 блоков)");
assertEquals(8, st1.lineLastNumber((short) 1), "USER1: line=1 должно быть 8 TEXT блоков");
assertEquals(2, st1.lineLastNumber((short) 2), "USER1: line=2 должно быть 2 REACTION блока");
assertEquals(10, st1.lastBlockNumber(), "USER1: lastBlockNumber должен быть 10 (всего 11 блоков включая HEADER)");
// USER2
ChainState st2 = new ChainState();
@ -95,7 +147,13 @@ public class IT_03_AddBlock_NoAuth {
sender2.send(new HeaderBody(u2), t);
assertTrue(st2.hasHeader());
sender2.send(new UserParamBody("Anya", "Amsterdam, Example street 10"), t);
// USER_PARAM (с line)
{
var ln = st2.nextLine(LineIndex.USER_PARAM);
sender2.send(new UserParamBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"Anya", "Amsterdam, Example street 10"
), t);
}
// USER3 (нужен, чтобы u1 мог подписаться на существующий блокчейн)
ChainState st3 = new ChainState();
@ -105,27 +163,70 @@ public class IT_03_AddBlock_NoAuth {
assertTrue(st3.hasHeader());
// -----------------------------------------------------------------
// Подписки (как ты просил):
// Подписки:
// - u1 follows u2 и u3
// - u2 follows только u1
// Все CONNECTION идут по линии CONNECTION (по ТЗ "да надо")
// -----------------------------------------------------------------
// u1 -> follow u2
sender1.send(new ConnectionBody(ConnectionBody.SUB_FOLLOW, u2, bch2, 0, new byte[32]), t);
{
var ln = st1.nextLine(LineIndex.CONNECTION);
sender1.send(new ConnectionBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.CONNECTION_FOLLOW,
u2, bch2, 0, new byte[32]
), t);
}
// u1 -> follow u3
sender1.send(new ConnectionBody(ConnectionBody.SUB_FOLLOW, u3, bch3, 0, new byte[32]), t);
{
var ln = st1.nextLine(LineIndex.CONNECTION);
sender1.send(new ConnectionBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.CONNECTION_FOLLOW,
u3, bch3, 0, new byte[32]
), t);
}
// u2 -> follow u1
sender2.send(new ConnectionBody(ConnectionBody.SUB_FOLLOW, u1, bch1, 0, new byte[32]), t);
{
var ln = st2.nextLine(LineIndex.CONNECTION);
sender2.send(new ConnectionBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.CONNECTION_FOLLOW,
u1, bch1, 0, new byte[32]
), t);
}
// (оставил твои friend/unfriend как было но они уже не обязательны для подписок)
sender2.send(new ConnectionBody(ConnectionBody.SUB_FRIEND, u1, bch1, 0, new byte[32]), t);
// friend/unfriend как было, но тоже по CONNECTION линии
{
var ln = st2.nextLine(LineIndex.CONNECTION);
sender2.send(new ConnectionBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.CONNECTION_FRIEND,
u1, bch1, 0, new byte[32]
), t);
}
sender1.send(new UserParamBody("Anna", "Gareeva"), t);
sender1.send(new ConnectionBody(ConnectionBody.SUB_FRIEND, u2, bch2, 0, new byte[32]), t);
// user1 param + friend to u2
{
var ln = st1.nextLine(LineIndex.USER_PARAM);
sender1.send(new UserParamBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"Anna", "Gareeva"
), t);
}
{
var ln = st1.nextLine(LineIndex.CONNECTION);
sender1.send(new ConnectionBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.CONNECTION_FRIEND,
u2, bch2, 0, new byte[32]
), t);
}
sender2.send(new ConnectionBody(ConnectionBody.SUB_UNFRIEND, u1, bch1, 0, new byte[32]), t);
{
var ln = st2.nextLine(LineIndex.CONNECTION);
sender2.send(new ConnectionBody(ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.CONNECTION_UNFRIEND,
u1, bch1, 0, new byte[32]
), t);
}
r.ok("IT_03 сценарий блоков выполнен");