Доделал тесты и названия линий сделал в константы

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
This commit is contained in:
AidarKC 2026-01-02 18:52:19 +03:00
parent be7a3ab7a6
commit c3d20ba338
11 changed files with 440 additions and 8 deletions

View File

@ -0,0 +1,17 @@
package blockchain;
/**
* LineIndex канонические номера линий блокчейна.
*
* Линия = независимая последовательность блоков внутри одного блокчейна.
*/
public final class LineIndex {
private LineIndex() {}
public static final short HEADER = 0; // genesis / идентификация
public static final short TEXT = 1; // сообщения
public static final short REACTION = 2; // реакции
public static final short CONNECTION = 3; // связи (friend/contact/follow)
public static final short USER_PARAM = 4; // параметры профиля
}

View File

@ -1,5 +1,7 @@
package blockchain.body;
import blockchain.LineIndex;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
@ -173,7 +175,7 @@ public final class ConnectionBody implements BodyRecord {
@Override
public short expectedLineIndex() {
return 3;
return LineIndex.CONNECTION;
}
@Override

View File

@ -1,5 +1,7 @@
package blockchain.body;
import blockchain.LineIndex;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
@ -93,8 +95,7 @@ public final class HeaderBody implements BodyRecord {
@Override
public short expectedLineIndex() {
return 0;
}
return LineIndex.HEADER; }
@Override
public HeaderBody check() {

View File

@ -1,5 +1,7 @@
package blockchain.body;
import blockchain.LineIndex;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
@ -114,7 +116,7 @@ public final class ReactionBody implements BodyRecord {
@Override
public short expectedLineIndex() {
return 2;
return LineIndex.REACTION;
}
@Override

View File

@ -1,5 +1,7 @@
package blockchain.body;
import blockchain.LineIndex;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
@ -223,7 +225,7 @@ public final class TextBody implements BodyRecord {
@Override
public short expectedLineIndex() {
return 1;
return LineIndex.TEXT;
}
@Override

View File

@ -1,5 +1,7 @@
package blockchain.body;
import blockchain.LineIndex;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
@ -138,7 +140,7 @@ public final class UserParamBody implements BodyRecord {
@Override
public short expectedLineIndex() {
return 4;
return LineIndex.USER_PARAM;
}
@Override

View File

@ -12,5 +12,9 @@ find . -type f -name "*.java" | sort | while read -r f; do
echo >> "$OUTFILE" # пустая строка-разделитель
done
echo "Готово! Все .java файлы собраны в $OUTFILE"
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -0,0 +1,196 @@
package test.it.addBlockUtils;
import blockchain.BchBlockEntry;
import blockchain.BchCryptoVerifier;
import blockchain.body.BodyRecord;
import test.it.utils.TestConfig;
import test.it.utils.TestLog;
import utils.crypto.Ed25519Util;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.time.Duration;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* AddBlockSender "одна кнопка":
* - принимает ГОТОВЫЙ Body (HeaderBody/TextBody/ReactionBody)
* - сам берёт номера/prev-hash из ChainState
* - строит raw/hash/signature
* - собирает BchBlockEntry (старый, без изменений)
* - отправляет AddBlock
* - проверяет serverLastGlobalHash == localHash
* - обновляет ChainState
*
* В тестах:
* sender.send(body, timeout);
*/
public final class AddBlockSender {
private static final byte[] ZERO32 = new byte[32];
private static final String ZERO64 = "0".repeat(64);
private final ChainState state;
public AddBlockSender(ChainState state) {
this.state = state;
}
public ChainState state() {
return state;
}
/**
* Отправить следующий блок по body.expectedLineIndex().
* Ничего не возвращает состояние хранится в ChainState.
*/
public void send(BodyRecord body, Duration timeout) {
if (body == null) throw new IllegalArgumentException("body == null");
short lineIndex = body.expectedLineIndex();
// header должен быть первым
if (lineIndex == 0) {
if (state.globalLastNumber() != -1) {
throw new IllegalStateException("HEADER должен быть первым: globalLastNumber уже " + state.globalLastNumber());
}
} else {
if (!state.hasHeader()) {
throw new IllegalStateException("Нельзя слать line=" + lineIndex + " до HEADER (нет headerHash32)");
}
}
int globalNumber = state.nextGlobalNumber();
int lineNumber = state.nextLineNumber(lineIndex);
byte[] prevGlobalHash32 = (lineIndex == 0) ? ZERO32 : state.prevGlobalHash32ForNext(lineIndex);
byte[] prevLineHash32 = (lineIndex == 0) ? ZERO32 : state.prevLineHash32ForNext(lineIndex);
long ts = System.currentTimeMillis() / 1000L;
byte[] bodyBytes = body.toBytes();
// RAW bytes (ровно то, что подписываем/хэшируем)
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();
// preimage -> sha256 -> signature
byte[] preimage = BchCryptoVerifier.buildPreimage(
TestConfig.LOGIN(),
prevGlobalHash32,
prevLineHash32,
rawBytes
);
byte[] hash32 = BchCryptoVerifier.sha256(preimage);
byte[] signature64 = Ed25519Util.sign(hash32, TestConfig.LOGIN_PRIV_KEY());
// Собираем полный блок (BchBlockEntry не меняем)
BchBlockEntry entry = new BchBlockEntry(
globalNumber,
ts,
lineIndex,
lineNumber,
bodyBytes,
signature64,
hash32
);
// отправляем JSON
String prevGlobalHashHex = (globalNumber == 0) ? ZERO64 : state.globalLastHashHex();
String req = buildAddBlockJson(
TestConfig.BCH_NAME(),
globalNumber,
prevGlobalHashHex,
base64(entry.toBytes())
);
String op = "AddBlock (global=" + globalNumber + ", line=" + lineIndex + ", lineNum=" + lineNumber + ")";
String resp = WsJsonOneShot.request(op, req, timeout);
assert200(op, resp);
String serverLastGlobalHash = 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 localHashHex = bytesToHex64(hash32);
if (TestConfig.DEBUG()) {
TestLog.ok(op + ": localHash=" + localHashHex);
TestLog.ok(op + ": serverLastGlobalHash=" + serverLastGlobalHash);
}
assertEquals(localHashHex, serverLastGlobalHash, op + ": serverLastGlobalHash must match local hash");
// обновляем ChainState
state.applyAppendedBlock(globalNumber, lineIndex, lineNumber, hash32);
if (TestConfig.DEBUG()) {
TestLog.ok(op + ": state updated");
}
}
// -------------------- json helpers --------------------
private static String buildAddBlockJson(String blockchainName,
int globalNumber,
String prevGlobalHashHex,
String blockBytesB64) {
return """
{
"op": "AddBlock",
"requestId": "%s",
"payload": {
"blockchainName": "%s",
"globalNumber": %d,
"prevGlobalHash": "%s",
"blockBytesB64": "%s"
}
}
""".formatted(WsJsonOneShot.FIXED_REQUEST_ID, blockchainName, globalNumber, prevGlobalHashHex, blockBytesB64);
}
private static void assert200(String op, String resp) {
int st = test.it.utils.JsonParsers.status(resp);
assertEquals(200, st, op + ": expected status=200, but got=" + st + ", resp=" + resp);
if (TestConfig.DEBUG()) TestLog.ok(op + ": status=200");
}
private static String extractPayloadString(String json, String field) {
try {
com.fasterxml.jackson.databind.JsonNode root =
new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
com.fasterxml.jackson.databind.JsonNode payload = root.get("payload");
if (payload != null && payload.has(field)) return payload.get(field).asText();
} catch (Exception ignore) {}
return null;
}
private static String base64(byte[] bytes) {
return Base64.getEncoder().encodeToString(bytes);
}
private static String bytesToHex64(byte[] b32) {
char[] out = new char[64];
final char[] HEX = "0123456789abcdef".toCharArray();
for (int i = 0; i < 32; i++) {
int v = b32[i] & 0xFF;
out[i * 2] = HEX[v >>> 4];
out[i * 2 + 1] = HEX[v & 0x0F];
}
return new String(out);
}
}

View File

@ -0,0 +1,182 @@
package test.it.addBlockUtils;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
/**
* ChainState только состояние цепочки (номера/хэши).
*
* Хранит:
* - last globalNumber / last globalHash
* - last lineNum / last lineHash по каждой линии
* - hash32 нулевого блока (headerHash32) нужен как prevLineHash для первого блока каждой линии
* - map globalNumber -> hash32 (для ссылок reply/reaction на старые блоки)
*/
public final class ChainState {
public static final int LINES_MAX = 8;
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;
private byte[] headerHash32 = null;
// Для удобства тестов: чтобы можно было делать reply/like на любой уже отправленный globalNumber
private final Map<Integer, byte[]> globalHash32ByNumber = new HashMap<>();
public ChainState() {
Arrays.fill(lineLastHashHex, "");
// lineLastNumber по умолчанию = 0
}
// -------------------- getters --------------------
public int globalLastNumber() { return globalLastNumber; }
public String globalLastHashHex() { return globalLastHashHex; }
public int lineLastNumber(short line) { return lineLastNumber[line]; }
public String lineLastHashHex(short line) { return lineLastHashHex[line]; }
public byte[] headerHash32() { return headerHash32 == null ? null : headerHash32.clone(); }
public byte[] getGlobalHash32(int globalNumber) {
byte[] h = globalHash32ByNumber.get(globalNumber);
return h == null ? null : h.clone();
}
// -------------------- state helpers --------------------
public boolean hasHeader() {
return headerHash32 != null && headerHash32.length == 32 && globalLastNumber >= 0;
}
/** Следующий globalNumber. */
public int nextGlobalNumber() {
return globalLastNumber + 1;
}
/** Следующий lineNumber: для line>0 — last+1. Для line0 — всегда 0 (header). */
public int nextLineNumber(short lineIndex) {
checkLine(lineIndex);
if (lineIndex == 0) return 0;
return lineLastNumber[lineIndex] + 1;
}
/** prevGlobalHash32: для header это ZERO32, иначе hash последнего глобального блока. */
public byte[] prevGlobalHash32ForNext(short nextLineIndex) {
// Для genesis/header prevGlobalHash = ZERO32
if (globalLastNumber < 0) return ZERO32;
return hexToBytes32(globalLastHashHex);
}
/**
* 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();
}
String lastHex = lineLastHashHex[lineIndex];
if (lastHex == null || lastHex.isBlank()) {
throw new IllegalStateException("lineLastHashHex[" + lineIndex + "] is blank but lineLastNumber>0");
}
return hexToBytes32(lastHex);
}
/**
* Применить факт успешного добавления блока:
* - обновить global last
* - обновить line last
* - сохранить globalNumber->hash32
* - если это header: сохранить headerHash32
*/
public void applyAppendedBlock(int globalNumber,
short lineIndex,
int lineNumber,
byte[] hash32) {
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);
}
checkLine(lineIndex);
if (lineIndex == 0) {
if (globalNumber != 0 || lineNumber != 0) {
throw new IllegalStateException("Header must be global=0 line=0 lineNum=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);
}
}
String hex64 = bytesToHex64(hash32);
globalLastNumber = globalNumber;
globalLastHashHex = hex64;
lineLastNumber[lineIndex] = lineNumber;
lineLastHashHex[lineIndex] = hex64;
globalHash32ByNumber.put(globalNumber, hash32.clone());
}
// -------------------- utils --------------------
private static void checkLine(short lineIndex) {
if (lineIndex < 0 || lineIndex >= LINES_MAX) {
throw new IllegalArgumentException("lineIndex must be 0.." + (LINES_MAX - 1));
}
}
private static byte[] hexToBytes32(String hex) {
if (hex == null) throw new IllegalArgumentException("hex is null");
String s = hex.trim();
if (s.length() != 64) throw new IllegalArgumentException("hex must be 64 chars, got " + s.length());
byte[] out = new byte[32];
for (int i = 0; i < 32; i++) {
int hi = Character.digit(s.charAt(i * 2), 16);
int lo = Character.digit(s.charAt(i * 2 + 1), 16);
if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex at pos " + (i * 2));
out[i] = (byte) ((hi << 4) | lo);
}
return out;
}
private static String bytesToHex64(byte[] b32) {
char[] out = new char[64];
final char[] HEX = "0123456789abcdef".toCharArray();
for (int i = 0; i < 32; i++) {
int v = b32[i] & 0xFF;
out[i * 2] = HEX[v >>> 4];
out[i * 2 + 1] = HEX[v & 0x0F];
}
return new String(out);
}
}

View File

@ -0,0 +1,24 @@
package test.it.addBlockUtils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* JsonMini маленькие утилиты, чтобы не раздувать зависимости.
*/
final class JsonMini {
private static final ObjectMapper M = new ObjectMapper();
private JsonMini() {}
static String extractPayloadString(String json, String field) {
try {
JsonNode root = M.readTree(json);
JsonNode payload = root.get("payload");
if (payload != null && payload.has(field)) {
JsonNode v = payload.get(field);
return (v == null || v.isNull()) ? null : v.asText();
}
} catch (Exception ignore) {}
return null;
}
}

View File

@ -27,7 +27,7 @@ public final class TestConfig {
public static final String WS_URI = "ws://localhost:7070/ws";
// ======= По умолчанию (можно поменять под свою среду) =======
public static final String DEFAULT_LOGIN = "anya24";
public static final String DEFAULT_LOGIN = "Anya";
// Суффикс блокчейна по твоему правилу: login + 3 цифры
public static final String DEFAULT_BCH_SUFFIX_3 = "001";