From e101e5c5f42371b68e388d7f41e6ca7421ce7b4f64a664703cba1ca1670fdaba Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 2 Oct 2026 00:33:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BB=D0=B8=D0=BD=D0=B8=D0=B8=20=D1=81=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=B5=D0=B9=20=D0=B8=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D1=80=D0=BE=D0=B2=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/blockchain/LineIndex.java | 2 +- .../java/blockchain/body/BodyHasLine.java | 26 +- .../java/blockchain/body/ConnectionBody.java | 65 +- .../main/java/blockchain/body/ForkBody.java | 51 +- .../java/blockchain/body/UserParamBody.java | 102 ++- .../test/java/blockchain/ForkBodyTest.java | 15 + .../blockchain/TargetLoginFormatTest.java | 16 +- .../src/main/java/shine/db/dao/BlocksDAO.java | 13 + .../src/main/resources/postgres/schema_v1.sql | 50 +- .../blockchain/Net_AddBlock_Handler.java | 141 +++- .../Net_KeyRotationAddBlock_Handler.java | 15 + .../Net_KeyRotationFinishChain_Handler.java | 13 + .../test/it/blockchain/AddBlockSender.java | 14 + .../java/test/it/blockchain/ChainState.java | 21 +- .../test/it/cases/IT_03_AddBlock_NoAuth.java | 4 - .../it/cases/SeedDataPopulationHelper.java | 2 - VERSION.properties | 4 +- .../00_Blockchain_Formats_and_Block_Types.md | 4 +- docs/Blockchain/01_Common_Block_Format.md | 28 +- .../02_Blockchain_Kinds_and_Lines.md | 104 ++- docs/Blockchain/10_TECH_Blocks.md | 105 ++- docs/Blockchain/13_CONNECTION_Blocks.md | 75 ++- docs/Blockchain/14_USER_PARAM_Blocks.md | 14 - docs/Blockchain/14_USER_PROFILE_Blocks.md | 45 ++ .../Blockchain/17_ANS104_Arweave_Transport.md | 12 +- docs/Blockchain/CHANGELOG.md | 11 + .../CODEX_APPLY_ANS104_TEST5590_PATCH.md | 1 + docs/Blockchain/README.md | 2 +- docs/Blockchain/TODO_BACK32_HASH.md | 203 ++++++ ...олнений_каналов_и_типов_сообщений_SHiNE.md | 6 +- shine-UI/js/services/auth-service.js | 629 +++++++++++------- shine-UI/js/services/key-rotation-service.js | 66 +- shine-solana-arweave-viewer-v3/index.html | 2 +- 33 files changed, 1293 insertions(+), 568 deletions(-) delete mode 100644 docs/Blockchain/14_USER_PARAM_Blocks.md create mode 100644 docs/Blockchain/14_USER_PROFILE_Blocks.md create mode 100644 docs/Blockchain/TODO_BACK32_HASH.md diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/LineIndex.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/LineIndex.java index 0638b42b..7451eecd 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/LineIndex.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/LineIndex.java @@ -13,5 +13,5 @@ // 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; // параметры профиля да надо +// public static final short USER_PROFILE= 4; // профиль пользователя да надо //} \ No newline at end of file diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/BodyHasLine.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/BodyHasLine.java index 35bac544..521fcdfe 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/BodyHasLine.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/BodyHasLine.java @@ -1,21 +1,23 @@ package blockchain.body; /** - * BodyHasLine — для типов, которые имеют линейные поля в body. + * BodyHasLine — типы блоков, которые входят в проверяемую логическую линию. * - * Line-prefix (BigEndian) в НАЧАЛЕ bodyBytes: - * [4] lineCode код линии (root-идентификатор): - * - 0 для дефолтной линии/канала "0" (root = HEADER, blockNumber=0) - * - для канала "X": blockNumber root-блока канала (CREATE_CHANNEL) + * Общая семантика: + * prevLineBlockGlobalNumber — GLOBAL blockNumber предыдущего блока этой линии; + * prevLineBlockHash32 — hash32 предыдущего блока этой линии; + * lineSeq — порядковый номер шага линии. * - * [4] prevLineBlockGlobalNumber глобальный номер предыдущего блока в этой линии - * [32] prevLineBlockHash32 hash32 предыдущего блока в этой линии + * lineCode — логический идентификатор линии: + * - 0 для фиксированных системных линий (TECH, CONNECTION, USER_PROFILE) + * и для дефолтного канала, корнем которого является HEADER #0; + * - для пользовательского канала: GLOBAL blockNumber его CREATE_CHANNEL root. * - * [4] lineSeq порядковый номер сообщения внутри линии (1..N) + * Важно: lineCode не обязан физически присутствовать в body. Для фиксированных + * USER_PROFILE и CONNECTION он выводится из msg_type и метод lineCode() возвращает 0. + * Для TEXT-каналов и TECH-блоков lineCode хранится в body явно. * - * Важно: - * - Проверка связности линии (prevLineBlockGlobalNumber ↔ prevLineBlockHash32) и корректности lineSeq - * выполняется на сервере/в БД при вставке (а не в body.check()). + * Полная проверка хвоста линии, prev hash и lineSeq выполняется сервером при AddBlock. */ public interface BodyHasLine { @@ -26,4 +28,4 @@ public interface BodyHasLine { byte[] prevLineBlockHash32(); int lineSeq(); -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java index 70e4e8b7..b76d6813 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java @@ -7,7 +7,10 @@ import java.nio.ByteOrder; import java.util.Arrays; import java.util.Objects; -/** CONNECTION with line fields and canonical external target. */ +/** + * CONNECTION with one fixed connection-history line and canonical external target. + * lineCode is not stored in body: the logical connection line always has code 0. + */ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine { public static final short TYPE = 3; public static final short VER = 1; @@ -15,10 +18,9 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL public final short subType; public final short version; - public final int lineCode; - public final int prevLineNumber; - public final byte[] prevLineHash32; - public final int thisLineNumber; + public final int prevConnectionBlockNumber; + public final byte[] prevConnectionBlockHash32; + public final int connectionSequence; public final String toLogin; public final int toForkNumber; public final int toBlockGlobalNumber; @@ -26,47 +28,59 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL public ConnectionBody(short subType, short version, byte[] bodyBytes) { Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - this.subType = subType; this.version = version; + this.subType = subType; + this.version = version; if ((version & 0xFFFF) != 1 || !isValidSubType(subType)) throw new IllegalArgumentException("Bad Connection header"); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); - BodyTargetSupport.ensureMin(bb, 4 + 4 + 32 + 4 + 1 + 1 + 2 + 4 + 32, "ConnectionBody too short"); - this.lineCode = bb.getInt(); this.prevLineNumber = bb.getInt(); this.prevLineHash32 = new byte[32]; bb.get(this.prevLineHash32); this.thisLineNumber = bb.getInt(); + BodyTargetSupport.ensureMin(bb, 4 + 32 + 4 + 1 + 1 + 2 + 4 + 32, "ConnectionBody too short"); + this.prevConnectionBlockNumber = bb.getInt(); + this.prevConnectionBlockHash32 = new byte[32]; + bb.get(this.prevConnectionBlockHash32); + this.connectionSequence = bb.getInt(); BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "CONNECTION", 0); - this.toLogin = t.login(); this.toForkNumber = t.forkNumber(); this.toBlockGlobalNumber = t.blockNumber(); this.toBlockHash32 = t.blockHash32(); + this.toLogin = t.login(); + this.toForkNumber = t.forkNumber(); + this.toBlockGlobalNumber = t.blockNumber(); + this.toBlockHash32 = t.blockHash32(); if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected ConnectionBody tail"); } - public ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, short subType, + public ConnectionBody(int prevConnectionBlockNumber, byte[] prevConnectionBlockHash32, int connectionSequence, short subType, String toLogin, int toForkNumber, int toBlockGlobalNumber, byte[] toBlockHash32) { - if (lineCode < 0 || !isValidSubType(subType)) throw new IllegalArgumentException("Bad connection fields"); + if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection fields"); BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION"); - this.lineCode = lineCode; 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.toForkNumber = toForkNumber; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + this.prevConnectionBlockNumber = prevConnectionBlockNumber; + this.prevConnectionBlockHash32 = prevConnectionBlockHash32 == null ? new byte[32] : Arrays.copyOf(prevConnectionBlockHash32, 32); + this.connectionSequence = connectionSequence; + this.subType = subType; + this.version = VER; + this.toLogin = toLogin; + this.toForkNumber = toForkNumber; + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); } @Override public ConnectionBody check() { - if (lineCode < 0 || !isValidSubType(subType)) throw new IllegalArgumentException("Bad connection fields"); - if (prevLineNumber == -1) { - if (!isAllZero32(prevLineHash32) || thisLineNumber != -1) throw new IllegalArgumentException("Bad empty connection line anchor"); - } else if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); + if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection fields"); + if (prevConnectionBlockNumber < 0) throw new IllegalArgumentException("prevConnectionBlockNumber must be >=0"); + if (prevConnectionBlockHash32 == null || prevConnectionBlockHash32.length != 32) throw new IllegalArgumentException("prevConnectionBlockHash32 invalid"); + if (connectionSequence <= 0) throw new IllegalArgumentException("connectionSequence must be >=1"); BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION"); return this; } @Override public byte[] toBytes() { check(); - ByteBuffer bb = ByteBuffer.allocate(4 + 4 + 32 + 4 + BodyTargetSupport.externalSize(toLogin)).order(ByteOrder.BIG_ENDIAN); - bb.putInt(lineCode).putInt(prevLineNumber).put(prevLineHash32).putInt(thisLineNumber); + ByteBuffer bb = ByteBuffer.allocate(4 + 32 + 4 + BodyTargetSupport.externalSize(toLogin)).order(ByteOrder.BIG_ENDIAN); + bb.putInt(prevConnectionBlockNumber).put(prevConnectionBlockHash32).putInt(connectionSequence); BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION"); return bb.array(); } - @Override public int lineCode() { return lineCode; } - @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } - @Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); } - @Override public int lineSeq() { return thisLineNumber; } + @Override public int lineCode() { return 0; } + @Override public int prevLineBlockGlobalNumber() { return prevConnectionBlockNumber; } + @Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevConnectionBlockHash32, 32); } + @Override public int lineSeq() { return connectionSequence; } @Override public String toLogin() { return toLogin; } @Override public Integer toForkNumber() { return toForkNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @@ -87,5 +101,4 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL || v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF) || v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF) || v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF) || v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF); } - private static boolean isAllZero32(byte[] b) { if (b == null || b.length != 32) return false; for (byte v : b) if (v != 0) return false; return true; } } diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ForkBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ForkBody.java index 37903223..64ededa2 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ForkBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ForkBody.java @@ -11,10 +11,15 @@ import java.util.Objects; /** * TECH_FORK body (type=0, subType=2, version=1). * - * Записывается первым новым блоком после точной перепубликации выбранного - * префикса старой цепочки новым blockchain key. + * TECH_FORK является обычным шагом общей TECH-линии. Поэтому body начинается + * тем же line-prefix, что и TECH_CREATE_CHANNEL. * * body bytes (BigEndian): + * [4] lineCode (=0 для TECH-линии) + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber + * * [32] parentBlockchainKey * [4] forkPointBlockNumber * [32] forkPointBlockHash32 @@ -27,7 +32,7 @@ import java.util.Objects; * [2] commentUtf8Length * [N] comment UTF-8 (0..1024 bytes) */ -public final class ForkBody implements BodyRecord { +public final class ForkBody implements BodyRecord, BodyHasLine { public static final short TYPE = 0; public static final short VER = 1; @@ -40,10 +45,18 @@ public final class ForkBody implements BodyRecord { public static final int REASON_RECOVERY = 4; public static final int MAX_COMMENT_UTF8_LEN = 1024; - private static final int FIXED_LEN = 32 + 4 + 32 + 8 + 4 + 32 + 8 + 4 + 1 + 2; + private static final int LINE_PREFIX_LEN = 4 + 4 + 32 + 4; + private static final int FIXED_PAYLOAD_LEN = 32 + 4 + 32 + 8 + 4 + 32 + 8 + 4 + 1 + 2; + private static final int FIXED_LEN = LINE_PREFIX_LEN + FIXED_PAYLOAD_LEN; public final short subType; public final short version; + + public final int lineCode; + public final int prevLineNumber; + public final byte[] prevLineHash32; + public final int thisLineNumber; + public final byte[] parentBlockchainKey32; public final int forkPointBlockNumber; public final byte[] forkPointBlockHash32; @@ -71,6 +84,12 @@ public final class ForkBody implements BodyRecord { } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + this.lineCode = bb.getInt(); + this.prevLineNumber = bb.getInt(); + this.prevLineHash32 = new byte[32]; + bb.get(this.prevLineHash32); + this.thisLineNumber = bb.getInt(); + this.parentBlockchainKey32 = new byte[32]; bb.get(this.parentBlockchainKey32); this.forkPointBlockNumber = bb.getInt(); @@ -95,7 +114,11 @@ public final class ForkBody implements BodyRecord { this.comment = new String(commentBytes, StandardCharsets.UTF_8); } - public ForkBody(byte[] parentBlockchainKey32, + public ForkBody(int lineCode, + int prevLineNumber, + byte[] prevLineHash32, + int thisLineNumber, + byte[] parentBlockchainKey32, int forkPointBlockNumber, byte[] forkPointBlockHash32, long forkPointTimestampMs, @@ -107,6 +130,10 @@ public final class ForkBody implements BodyRecord { String comment) { this.subType = SUBTYPE; this.version = VER; + this.lineCode = lineCode; + this.prevLineNumber = prevLineNumber; + this.prevLineHash32 = copy32(prevLineHash32, "prevLineHash32"); + this.thisLineNumber = thisLineNumber; this.parentBlockchainKey32 = copy32(parentBlockchainKey32, "parentBlockchainKey32"); this.forkPointBlockNumber = forkPointBlockNumber; this.forkPointBlockHash32 = copy32(forkPointBlockHash32, "forkPointBlockHash32"); @@ -127,6 +154,11 @@ public final class ForkBody implements BodyRecord { if ((version & 0xFFFF) != (VER & 0xFFFF)) { throw new IllegalArgumentException("ForkBody version must be 1"); } + if (lineCode != 0) throw new IllegalArgumentException("ForkBody lineCode must be 0"); + if (prevLineNumber < 0) throw new IllegalArgumentException("prevLineNumber must be >=0"); + require32(prevLineHash32, "prevLineHash32"); + if (thisLineNumber <= 0) throw new IllegalArgumentException("thisLineNumber must be >=1"); + require32(parentBlockchainKey32, "parentBlockchainKey32"); require32(forkPointBlockHash32, "forkPointBlockHash32"); require32(parentTipBlockHash32, "parentTipBlockHash32"); @@ -155,6 +187,10 @@ public final class ForkBody implements BodyRecord { check(); byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8); ByteBuffer bb = ByteBuffer.allocate(FIXED_LEN + commentUtf8.length).order(ByteOrder.BIG_ENDIAN); + bb.putInt(lineCode); + bb.putInt(prevLineNumber); + bb.put(prevLineHash32); + bb.putInt(thisLineNumber); bb.put(parentBlockchainKey32); bb.putInt(forkPointBlockNumber); bb.put(forkPointBlockHash32); @@ -169,6 +205,11 @@ public final class ForkBody implements BodyRecord { return bb.array(); } + @Override public int lineCode() { return lineCode; } + @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } + @Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); } + @Override public int lineSeq() { return thisLineNumber; } + public static boolean isReasonSupported(int code) { return code == REASON_ROUTINE_ROTATION || code == REASON_POSSIBLE_COMPROMISE diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/UserParamBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/UserParamBody.java index 0ff58aab..519355cc 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/UserParamBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/UserParamBody.java @@ -11,16 +11,16 @@ import java.util.Arrays; import java.util.Objects; /** - * UserParamBody — type=4, ver=1 (в заголовке блока). + * UserParamBody — type=4, ver=1 (в документации: USER_PROFILE). * * subType (в заголовке блока): * 1 = TEXT_TEXT * - * bodyBytes (BigEndian), новый формат: - * [4] lineCode - * [4] prevLineNumber - * [32] prevLineHash32 - * [4] thisLineNumber + * USER_PROFILE имеет единственную фиксированную линию, поэтому lineCode в body не хранится. + * bodyBytes (BigEndian): + * [4] prevProfileBlockNumber — GLOBAL blockNumber предыдущего USER_PROFILE-блока + * [32] prevProfileBlockHash32 — hash32 предыдущего USER_PROFILE-блока + * [4] profileSequence — порядковый номер изменения профиля (1..N) * * [2] keyLenBytes (uint16) * [N] keyUtf8 @@ -35,14 +35,12 @@ public final class UserParamBody implements BodyRecord, BodyHasLine { public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); - public final short subType; // из header - public final short version; // из header + public final short subType; + public final short version; - // line - public final int lineCode; - public final int prevLineNumber; - public final byte[] prevLineHash32; - public final int thisLineNumber; + public final int prevProfileBlockNumber; + public final byte[] prevProfileBlockHash32; + public final int profileSequence; public final String paramKey; public final String paramValue; @@ -60,21 +58,17 @@ public final class UserParamBody implements BodyRecord, BodyHasLine { throw new IllegalArgumentException("Bad UserParam subType: " + (this.subType & 0xFFFF)); } - // минимум: lineCode(4)+line(4+32+4) + keyLen(2)+key(1) + valLen(2)+val(1) - if (bodyBytes.length < 4 + (4 + 32 + 4) + 2 + 1 + 2 + 1) { + // prevGlobal(4)+prevHash(32)+seq(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); - this.lineCode = bb.getInt(); - - this.prevLineNumber = bb.getInt(); - - this.prevLineHash32 = new byte[32]; - bb.get(this.prevLineHash32); - - this.thisLineNumber = bb.getInt(); + this.prevProfileBlockNumber = bb.getInt(); + this.prevProfileBlockHash32 = new byte[32]; + bb.get(this.prevProfileBlockHash32); + this.profileSequence = bb.getInt(); int keyLen = Short.toUnsignedInt(bb.getShort()); if (keyLen <= 0) throw new IllegalArgumentException("paramKeyLen is 0"); @@ -99,25 +93,21 @@ public final class UserParamBody implements BodyRecord, BodyHasLine { if (this.paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); } - public UserParamBody(int lineCode, - int prevLineNumber, - byte[] prevLineHash32, - int thisLineNumber, + public UserParamBody(int prevProfileBlockNumber, + byte[] prevProfileBlockHash32, + int profileSequence, String paramKey, String paramValue) { Objects.requireNonNull(paramKey, "paramKey == null"); Objects.requireNonNull(paramValue, "paramValue == null"); - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - this.subType = MsgSubType.USER_PARAM_TEXT_TEXT; this.version = VER; - this.lineCode = lineCode; - this.prevLineNumber = prevLineNumber; - this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - this.thisLineNumber = thisLineNumber; + this.prevProfileBlockNumber = prevProfileBlockNumber; + this.prevProfileBlockHash32 = (prevProfileBlockHash32 == null ? new byte[32] : Arrays.copyOf(prevProfileBlockHash32, 32)); + this.profileSequence = profileSequence; if (paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); if (paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); @@ -128,17 +118,12 @@ public final class UserParamBody implements BodyRecord, BodyHasLine { @Override public UserParamBody check() { - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - - if ((subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF)) + if ((subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF)) { throw new IllegalArgumentException("Bad UserParam subType: " + (subType & 0xFFFF)); - - 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 (prevProfileBlockNumber < 0) throw new IllegalArgumentException("prevProfileBlockNumber must be >=0"); + if (prevProfileBlockHash32 == null || prevProfileBlockHash32.length != 32) throw new IllegalArgumentException("prevProfileBlockHash32 invalid"); + if (profileSequence <= 0) throw new IllegalArgumentException("profileSequence must be >=1"); if (paramKey == null || paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); if (paramValue == null || paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); @@ -148,30 +133,25 @@ public final class UserParamBody implements BodyRecord, BodyHasLine { @Override public byte[] toBytes() { + check(); byte[] keyUtf8 = paramKey.getBytes(StandardCharsets.UTF_8); byte[] valUtf8 = paramValue.getBytes(StandardCharsets.UTF_8); 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"); - int cap = 4 + (4 + 32 + 4) + int cap = 4 + 32 + 4 + 2 + keyUtf8.length + 2 + valUtf8.length; ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - - bb.putInt(lineCode); - - bb.putInt(prevLineNumber); - bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - bb.putInt(thisLineNumber); - + bb.putInt(prevProfileBlockNumber); + bb.put(prevProfileBlockHash32); + bb.putInt(profileSequence); bb.putShort((short) keyUtf8.length); bb.put(keyUtf8); - bb.putShort((short) valUtf8.length); bb.put(valUtf8); - return bb.array(); } @@ -187,15 +167,9 @@ public final class UserParamBody implements BodyRecord, BodyHasLine { } } - 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 lineCode() { return lineCode; } - @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } - @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } - @Override public int lineSeq() { return thisLineNumber; } -} \ No newline at end of file + /* USER_PROFILE имеет единственную линию, логический lineCode всегда 0. */ + @Override public int lineCode() { return 0; } + @Override public int prevLineBlockGlobalNumber() { return prevProfileBlockNumber; } + @Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevProfileBlockHash32, 32); } + @Override public int lineSeq() { return profileSequence; } +} diff --git a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/ForkBodyTest.java b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/ForkBodyTest.java index 13593fe5..eb2a3561 100644 --- a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/ForkBodyTest.java +++ b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/ForkBodyTest.java @@ -16,6 +16,10 @@ final class ForkBodyTest { byte[] tipHash = filled(32, 0x33); ForkBody source = new ForkBody( + 0, + 77, + filled(32, 0x44), + 5, parentKey, 120, forkHash, @@ -33,6 +37,9 @@ final class ForkBodyTest { BodyRecordParser.parse(ForkBody.TYPE, ForkBody.SUBTYPE, ForkBody.VER, source.toBytes()) ); + assertEquals(0, parsed.lineCode); + assertEquals(77, parsed.prevLineNumber); + assertEquals(5, parsed.thisLineNumber); assertArrayEquals(parentKey, parsed.parentBlockchainKey32); assertEquals(120, parsed.forkPointBlockNumber); assertArrayEquals(forkHash, parsed.forkPointBlockHash32); @@ -46,6 +53,10 @@ final class ForkBodyTest { @Test void routineRotationMayDiscardNothing() { ForkBody body = new ForkBody( + 0, + 0, + filled(32, 9), + 1, filled(32, 1), 10, filled(32, 2), @@ -63,6 +74,10 @@ final class ForkBodyTest { @Test void rejectedWhenDiscardCountDoesNotMatchForkPointAndTip() { ForkBody body = new ForkBody( + 0, + 0, + filled(32, 9), + 1, filled(32, 1), 10, filled(32, 2), diff --git a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/TargetLoginFormatTest.java b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/TargetLoginFormatTest.java index 4ebfc5fe..d5f89ff9 100644 --- a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/TargetLoginFormatTest.java +++ b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/TargetLoginFormatTest.java @@ -59,7 +59,7 @@ final class TargetLoginFormatTest { assertEquals(5, parsedEditPost.toBlockGlobalNumber()); ConnectionBody connection = new ConnectionBody( - 0, -1, new byte[32], -1, + 0, HASH, 1, MsgSubType.CONNECTION_FOLLOW, "alice", 3, 0, HASH ); @@ -84,16 +84,10 @@ final class TargetLoginFormatTest { } @Test - void forkZeroIsRejectedForNewProtocol() { - assertThrows(IllegalArgumentException.class, - () -> new ReactionBody(MsgSubType.REACTION_LIKE, "alice", 0, 42, HASH)); - - byte[] encoded = new ReactionBody(MsgSubType.REACTION_LIKE, "alice", 1, 42, HASH).toBytes(); - int forkOffset = 1 + "alice".getBytes(java.nio.charset.StandardCharsets.UTF_8).length; - encoded[forkOffset] = 0; - encoded[forkOffset + 1] = 0; - assertThrows(IllegalArgumentException.class, - () -> new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, encoded)); + void forkZeroIsReservedButStillDecodableAsHistoricalUnknown() { + ReactionBody source = new ReactionBody(MsgSubType.REACTION_LIKE, "alice", 0, 42, HASH); + ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check(); + assertEquals(0, parsed.toForkNumber()); } @Test diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java index 943bf16b..85bb4518 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java @@ -122,6 +122,19 @@ public final class BlocksDAO { } public BlockEntry getByNumber(String bchName,int blockNumber)throws SQLException{try(Connection c=db.getConnection()){return getByNumber(c,bchName,blockNumber);} } + /** Последний принятый блок конкретной логической линии в текущем physical fork. */ + public BlockEntry getLastLineBlock(Connection c, String bchName, int msgType, int lineCode) throws SQLException { + String sql = baseSelect()+" WHERE login=? AND fork_number=? AND msg_type=? AND line_code=? ORDER BY block_number DESC LIMIT 1"; + ChainRef r=chainRef(bchName); + try(PreparedStatement ps=c.prepareStatement(sql)){ + ps.setString(1,r.login()); ps.setInt(2,r.fork()); ps.setInt(3,msgType); ps.setInt(4,lineCode); + try(ResultSet rs=ps.executeQuery()){ return rs.next()?mapRow(rs):null; } + } + } + public BlockEntry getLastLineBlock(String bchName, int msgType, int lineCode) throws SQLException { + try(Connection c=db.getConnection()){ return getLastLineBlock(c,bchName,msgType,lineCode); } + } + public List listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} } public List listRangeByNumber(Connection c,String bchName,int from,int to)throws SQLException{ String sql=baseSelect()+" WHERE login=? AND fork_number=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC"; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 39db74be..dc0b5922 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -523,7 +523,7 @@ CREATE TABLE IF NOT EXISTS blocks ( block_bytes BYTEA NOT NULL, to_login TEXT, to_bch_name TEXT, - to_fork_number INTEGER CHECK (to_fork_number IS NULL OR (to_fork_number >= 1 AND to_fork_number <= 999)), + to_fork_number INTEGER CHECK (to_fork_number IS NULL OR (to_fork_number >= 0 AND to_fork_number <= 999)), to_block_number INTEGER CHECK (to_block_number IS NULL OR to_block_number >= 0), to_block_hash BYTEA, block_hash BYTEA NOT NULL, @@ -1110,6 +1110,7 @@ LANGUAGE plpgsql AS $$ DECLARE prev_block RECORD; + tail_block_number INTEGER; BEGIN IF NEW.line_code IS NULL AND NEW.prev_line_number IS NULL @@ -1149,26 +1150,35 @@ BEGIN RAISE EXCEPTION 'LINE_ERR_LINE_CODE_MISMATCH'; END IF; - IF NEW.prev_line_number = NEW.line_code THEN + SELECT block_number + INTO tail_block_number + FROM blocks + WHERE bch_name = NEW.bch_name + AND msg_type = NEW.msg_type + AND line_code = NEW.line_code + ORDER BY block_number DESC + LIMIT 1; + + IF tail_block_number IS NULL THEN + IF NEW.prev_line_number <> NEW.line_code THEN + RAISE EXCEPTION 'LINE_ERR_NOT_CURRENT_TAIL'; + END IF; IF NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END) THEN RAISE EXCEPTION 'LINE_ERR_FIRST_STEP_BAD_THIS'; END IF; RETURN NEW; END IF; + IF NEW.prev_line_number <> tail_block_number THEN + RAISE EXCEPTION 'LINE_ERR_NOT_CURRENT_TAIL'; + END IF; + IF prev_block.this_line_number IS NULL THEN RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; END IF; - IF NEW.msg_type = 1 THEN - IF NEW.this_line_number <> prev_block.this_line_number - AND NEW.this_line_number <> prev_block.this_line_number + 1 THEN - RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; - END IF; - ELSE - IF NEW.this_line_number <> prev_block.this_line_number + 1 THEN - RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; - END IF; + IF NEW.this_line_number <> prev_block.this_line_number + 1 THEN + RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; END IF; RETURN NEW; @@ -2436,7 +2446,7 @@ $$; -- Physical chain integrity uses numeric fork identity. CREATE OR REPLACE FUNCTION shine_blocks_line_integrity_bi() RETURNS TRIGGER LANGUAGE plpgsql AS $$ -DECLARE prev_block RECORD; +DECLARE prev_block RECORD; tail_block_number INTEGER; BEGIN IF NEW.line_code IS NULL AND NEW.prev_line_number IS NULL AND NEW.prev_line_hash IS NULL AND NEW.this_line_number IS NULL THEN RETURN NEW; END IF; IF NEW.msg_type NOT IN (0,1,3,4) THEN RAISE EXCEPTION 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE'; END IF; @@ -2445,15 +2455,17 @@ BEGIN IF NOT FOUND THEN RAISE EXCEPTION 'LINE_ERR_NO_PREV'; END IF; IF prev_block.block_hash IS DISTINCT FROM NEW.prev_line_hash THEN RAISE EXCEPTION 'LINE_ERR_PREV_HASH_MISMATCH'; END IF; IF NEW.prev_line_number<>NEW.line_code AND prev_block.line_code IS DISTINCT FROM NEW.line_code THEN RAISE EXCEPTION 'LINE_ERR_LINE_CODE_MISMATCH'; END IF; - IF NEW.prev_line_number=NEW.line_code THEN - IF NEW.this_line_number<>(CASE WHEN NEW.msg_type=1 THEN 0 ELSE 1 END) THEN RAISE EXCEPTION 'LINE_ERR_FIRST_STEP_BAD_THIS'; END IF; RETURN NEW; + SELECT block_number INTO tail_block_number FROM blocks_store + WHERE login=NEW.login AND fork_number=NEW.fork_number AND msg_type=NEW.msg_type AND line_code=NEW.line_code + ORDER BY block_number DESC LIMIT 1; + IF tail_block_number IS NULL THEN + IF NEW.prev_line_number<>NEW.line_code THEN RAISE EXCEPTION 'LINE_ERR_NOT_CURRENT_TAIL'; END IF; + IF NEW.this_line_number<>(CASE WHEN NEW.msg_type=1 THEN 0 ELSE 1 END) THEN RAISE EXCEPTION 'LINE_ERR_FIRST_STEP_BAD_THIS'; END IF; + RETURN NEW; END IF; + IF NEW.prev_line_number<>tail_block_number THEN RAISE EXCEPTION 'LINE_ERR_NOT_CURRENT_TAIL'; END IF; IF prev_block.this_line_number IS NULL THEN RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; END IF; - IF NEW.msg_type=1 THEN - IF NEW.this_line_number<>prev_block.this_line_number AND NEW.this_line_number<>prev_block.this_line_number+1 THEN RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; END IF; - ELSE - IF NEW.this_line_number<>prev_block.this_line_number+1 THEN RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; END IF; - END IF; + IF NEW.this_line_number<>prev_block.this_line_number+1 THEN RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; END IF; RETURN NEW; END $$; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 7cbb3fb2..2ae3fd97 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -8,6 +8,7 @@ import blockchain.body.BodyHasTarget; import blockchain.body.ConnectionBody; import blockchain.body.CreateChannelBody; import blockchain.body.HeaderBody; +import blockchain.body.ForkBody; import blockchain.body.StatusActionBody; import blockchain.body.TextReplyBody; import blockchain.body.TextLineBody; @@ -195,6 +196,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { case "limit_check_failed" -> "Ошибка проверки лимита размера"; case "bad_block_format" -> "Некорректный формат блока"; case "bad_app_tag" -> "Некорректный тестовый тег ANS-104"; + case "bad_type_tag" -> "Некорректный индексный t-тег ANS-104"; case "bad_channel_tag" -> "Некорректный тег канала ANS-104"; case "bad_block_body" -> "Некорректное тело блока"; case "bad_channel_name" -> "Некорректное название канала"; @@ -204,9 +206,15 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { case "bad_blockchain_key_len" -> "Некорректный ключ блокчейна в состоянии (ожидалось 32 байта)"; case "signature_verify_failed" -> "Ошибка проверки подписи блока"; case "bad_signature" -> "Некорректная подпись блока"; - case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии"; + case "prev_line_block_not_found", "line_root_not_found" -> "Не найден root/prev блок логической линии"; + case "bad_line_code" -> "Некорректный код логической линии"; + case "bad_line_root" -> "Некорректный root логической линии"; + case "bad_line_state" -> "Повреждено состояние логической линии"; + case "bad_prev_line_number" -> "Блок не продолжает текущий хвост логической линии"; case "bad_prev_line_hash" -> "Некорректный prevLineHash"; - case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine"; + case "bad_line_sequence" -> "Некорректный порядковый номер логической линии"; + case "unsupported_line_body" -> "Этот body не поддерживает логическую линию"; + case "db_error_prev_line_check", "db_error_line_check" -> "Ошибка БД при проверке логической линии"; case "channel_name_already_exists" -> "Такое название канала уже занято"; case "repost_disabled" -> "Репосты временно отключены до будущей реализации"; case "entrypoint_edit_forbidden" -> "TEXT_ENTRYPOINT нельзя редактировать через TEXT_EDIT_POST"; @@ -312,6 +320,17 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_app_tag", serverLastNum, serverLastHashHex); } + // Signed secondary index for the fixed SHiNE streams. + String expectedTypeTag = expectedTypeTag(block); + String actualTypeTag = block.getDataItem().tagValue("t"); + if (expectedTypeTag != null) { + if (!expectedTypeTag.equals(actualTypeTag)) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_type_tag", serverLastNum, serverLastHashHex); + } + } else if (actualTypeTag != null) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_type_tag", serverLastNum, serverLastHashHex); + } + // body.check() try { block.body.check(); @@ -540,6 +559,11 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex); } + if (serverLastNum == -1 && block.body instanceof HeaderBody headerBody + && !Arrays.equals(headerBody.initialBlockchainKey32, pubKey32)) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "header_initial_key_mismatch", serverLastNum, serverLastHashHex); + } + // 6) ANS-104 Ed25519 подпись и owner boolean sigOk; try { @@ -556,7 +580,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex); } - // 7) line columns (only for BodyHasLine) + // 7) line columns + strict continuity check. Integer lineCode = null; Integer prevLineNumber = null; byte[] prevLineHash32 = null; @@ -568,33 +592,17 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { prevLineHash32 = bl.prevLineBlockHash32(); thisLineNumber = bl.lineSeq(); - // Нормализация: пустой line anchor (-1) не пишем в DB projection - if (prevLineNumber != null && prevLineNumber == -1) { - lineCode = null; - prevLineNumber = null; - prevLineHash32 = null; - thisLineNumber = null; - } - - // Если prevLineNumber задан — проверяем его хэш - if (prevLineNumber != null) { - try { - 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.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); + try { + String lineError = validateLineContinuity(blockchainName, block, bl); + if (lineError != null) { + log.warn("AddBlock: {} (login={}, blockchainName={}, blockNumber={}, lineCode={}, prevLineNumber={}, lineSeq={})", + lineError, login, blockchainName, block.blockNumber, lineCode, prevLineNumber, thisLineNumber); + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, lineError, serverLastNum, serverLastHashHex); } + } catch (Exception e) { + log.error("AddBlock: db_error_line_check (login={}, blockchainName={}, blockNumber={})", + login, blockchainName, block.blockNumber, e); + return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error_line_check", serverLastNum, serverLastHashHex); } } @@ -755,6 +763,81 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { /* ====================== Helpers ====================================== */ /* ===================================================================== */ + private static String expectedTypeTag(BchBlockEntry block) { + int type = block.type & 0xffff; + return switch (type) { + case 0 -> "tech"; + case 3 -> "conn"; + case 4 -> "prof"; + default -> null; + }; + } + + /** + * Проверяет не просто существование prev-блока, а именно продолжение текущего хвоста линии. + * Это запрещает боковые ветки линии и гарантирует монотонный sequence. + */ + private String validateLineContinuity(String blockchainName, BchBlockEntry block, BodyHasLine line) throws Exception { + int type = block.type & 0xffff; + int lineCode = line.lineCode(); + int rootBlockNumber; + int firstSequence; + + if (block.body instanceof UserParamBody || block.body instanceof ConnectionBody) { + if (lineCode != 0) return "bad_line_code"; + rootBlockNumber = 0; + firstSequence = 1; + } else if (block.body instanceof CreateChannelBody || block.body instanceof ForkBody) { + if (lineCode != 0) return "bad_line_code"; + rootBlockNumber = 0; + firstSequence = 1; + } else if (block.body instanceof TextLineBody) { + if (lineCode < 0) return "bad_line_code"; + rootBlockNumber = lineCode; + // Channel sequence is zero-based after its root anchor. + firstSequence = 0; + } else { + return "unsupported_line_body"; + } + + BlockEntry root = blocksDAO.getByNumber(blockchainName, rootBlockNumber); + if (root == null) return "line_root_not_found"; + if (rootBlockNumber == 0) { + try { + if (!(new BchBlockEntry(root.getBlockBytes()).body instanceof HeaderBody)) return "bad_line_root"; + } catch (Exception e) { + return "bad_line_root"; + } + } else { + try { + if (!(new BchBlockEntry(root.getBlockBytes()).body instanceof CreateChannelBody)) return "bad_line_root"; + } catch (Exception e) { + return "bad_line_root"; + } + } + + BlockEntry tail = blocksDAO.getLastLineBlock(blockchainName, type, lineCode); + int expectedPrevNumber; + byte[] expectedPrevHash; + int expectedSequence; + if (tail == null) { + expectedPrevNumber = rootBlockNumber; + expectedPrevHash = root.getBlockHash(); + expectedSequence = firstSequence; + } else { + if (tail.getThisLineNumber() == null) return "bad_line_state"; + expectedPrevNumber = tail.getBlockNumber(); + expectedPrevHash = tail.getBlockHash(); + expectedSequence = Math.addExact(tail.getThisLineNumber(), 1); + } + + if (line.prevLineBlockGlobalNumber() != expectedPrevNumber) return "bad_prev_line_number"; + byte[] suppliedPrevHash = require32OrThrow(line.prevLineBlockHash32(), "prevLineHash32 invalid"); + if (!Arrays.equals(expectedPrevHash, suppliedPrevHash)) return "bad_prev_line_hash"; + if (line.lineSeq() != expectedSequence) return "bad_line_sequence"; + return null; + } + private String expectedChannelSlug(String blockchainName, BchBlockEntry block, ChannelNameStateEntry justCreated) throws Exception { if (block.body instanceof CreateChannelBody) { return justCreated == null ? null : justCreated.getSlug(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationAddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationAddBlock_Handler.java index 570bcd34..cba00590 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationAddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationAddBlock_Handler.java @@ -101,6 +101,11 @@ public final class Net_KeyRotationAddBlock_Handler implements JsonMessageHandler if (!block.getDataItem().hasTag("App", "test5590")) { return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_APP_TAG", "Отсутствует App=test5590"); } + String expectedTypeTag = expectedTypeTag(block); + String actualTypeTag = block.getDataItem().tagValue("t"); + if (expectedTypeTag != null ? !expectedTypeTag.equals(actualTypeTag) : actualTypeTag != null) { + return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_TYPE_TAG", "Некорректный t-тег candidate DataItem"); + } if (!requestPrevHashMatches(req.getPrevBlockHash(), block.prevHash32)) { return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_PREV_HASH_MISMATCH", "prevBlockHash запроса не совпадает с блоком"); } @@ -221,4 +226,14 @@ public final class Net_KeyRotationAddBlock_Handler implements JsonMessageHandler if (value == null) return ""; return value.trim().replace("\r\n", "\n").replace('\r', '\n'); } + private static String expectedTypeTag(BchBlockEntry block) { + int type = block.type & 0xffff; + return switch (type) { + case 0 -> "tech"; + case 3 -> "conn"; + case 4 -> "prof"; + default -> null; + }; + } + } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationFinishChain_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationFinishChain_Handler.java index e76f87e4..af2391b3 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationFinishChain_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/keyRotation/Net_KeyRotationFinishChain_Handler.java @@ -148,6 +148,9 @@ public final class Net_KeyRotationFinishChain_Handler implements JsonMessageHand if (!Arrays.equals(candidate.getHash32(), stored.getBlockHash())) return "KEY_ROTATION_STORED_HASH_MISMATCH"; if (!Arrays.equals(candidate.getDataItemId32(), stored.getDataItemId())) return "KEY_ROTATION_STORED_DATAITEM_MISMATCH"; if (!candidate.getDataItem().hasTag("App", "test5590")) return "KEY_ROTATION_BAD_APP_TAG"; + String expectedTypeTag = expectedTypeTag(candidate); + String actualTypeTag = candidate.getDataItem().tagValue("t"); + if (expectedTypeTag != null ? !expectedTypeTag.equals(actualTypeTag) : actualTypeTag != null) return "KEY_ROTATION_BAD_TYPE_TAG"; if (!BchCryptoVerifier.verifyBlock(candidate, newBlockchainKey)) return "KEY_ROTATION_BAD_SIGNATURE"; if (i == 0) { @@ -215,4 +218,14 @@ public final class Net_KeyRotationFinishChain_Handler implements JsonMessageHand if (value == null) return ""; return value.trim().replace("\r\n", "\n").replace('\r', '\n'); } + private static String expectedTypeTag(BchBlockEntry block) { + int type = block.type & 0xffff; + return switch (type) { + case 0 -> "tech"; + case 3 -> "conn"; + case 4 -> "prof"; + default -> null; + }; + } + } diff --git a/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java b/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java index 2e2e1685..73c25cd4 100644 --- a/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java +++ b/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java @@ -81,6 +81,8 @@ public final class AddBlockSender { List tags = new ArrayList<>(); tags.add(new Ans104DataItem.Tag("App", "test5590")); + String typeTag = typeTagFor(type); + if (typeTag != null) tags.add(new Ans104DataItem.Tag("t", typeTag)); String channelSlug = channelSlugFor(body); if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c_test5590", channelSlug)); @@ -167,6 +169,7 @@ public final class AddBlockSender { private static short typeOf(BodyRecord body) { if (body instanceof HeaderBody) return HeaderBody.TYPE; if (body instanceof CreateChannelBody) return CreateChannelBody.TYPE; + if (body instanceof ForkBody) return ForkBody.TYPE; if (body instanceof TextLineBody || body instanceof TextReplyBody || body instanceof TextRatingBody) return TextLineBody.TYPE; if (body instanceof StatusActionBody) return StatusActionBody.TYPE; if (body instanceof ReactionBody) return ReactionBody.TYPE; @@ -178,6 +181,7 @@ public final class AddBlockSender { private static short subTypeOf(BodyRecord body) { if (body instanceof HeaderBody hb) return hb.subType; if (body instanceof CreateChannelBody cb) return cb.subType; + if (body instanceof ForkBody fb) return fb.subType; if (body instanceof TextLineBody tb) return tb.subType; if (body instanceof TextReplyBody tb) return tb.subType; if (body instanceof TextRatingBody tb) return tb.subType; @@ -191,6 +195,7 @@ public final class AddBlockSender { private static short versionOf(BodyRecord body) { if (body instanceof HeaderBody hb) return hb.version; if (body instanceof CreateChannelBody cb) return cb.version; + if (body instanceof ForkBody fb) return fb.version; if (body instanceof TextLineBody tb) return tb.version; if (body instanceof TextReplyBody tb) return tb.version; if (body instanceof TextRatingBody tb) return tb.version; @@ -203,6 +208,15 @@ public final class AddBlockSender { // ---------- Frame v1 + signed channel tag helpers ---------- + private static String typeTagFor(short type) { + return switch (type & 0xFFFF) { + case 0 -> "tech"; + case 3 -> "conn"; + case 4 -> "prof"; + default -> null; + }; + } + private String channelSlugFor(BodyRecord body) { if (body instanceof CreateChannelBody ccb) { return shine.db.channels.ChannelNameRules.toCanonicalSlug(ccb.channelName); diff --git a/SHiNE-server/src/test/java/test/it/blockchain/ChainState.java b/SHiNE-server/src/test/java/test/it/blockchain/ChainState.java index 16b14a78..737c1335 100644 --- a/SHiNE-server/src/test/java/test/it/blockchain/ChainState.java +++ b/SHiNE-server/src/test/java/test/it/blockchain/ChainState.java @@ -3,6 +3,7 @@ package test.it.blockchain; import blockchain.body.BodyRecord; import blockchain.body.BodyHasLine; import blockchain.body.CreateChannelBody; +import blockchain.body.ForkBody; import blockchain.body.TextLineBody; import java.util.HashMap; @@ -16,10 +17,10 @@ import java.util.Map; * - map blockNumber -> hash32 * * Линии: - * - TECH (type=0): только CREATE_CHANNEL (hasLine), root = HEADER + * - TECH (type=0): CREATE_CHANNEL и TECH_FORK, root = HEADER * - TEXT (type=1): линии каналов, root = HEADER (канал "0") или CREATE_CHANNEL (канал "X") * - CONNECTION (type=3): одна линия - * - USER_PARAM (type=4): одна линия + * - USER_PROFILE (type=4): одна линия * * ВАЖНО: * - prevLineNumber — это GLOBAL blockNumber предыдущего блока линии. @@ -277,16 +278,18 @@ public final class ChainState { int t = type & 0xFFFF; - // ---- TECH (CREATE_CHANNEL) ---- - if (t == TYPE_TECH && body instanceof CreateChannelBody ccb) { + // ---- TECH (CREATE_CHANNEL / TECH_FORK) ---- + if (t == TYPE_TECH && body instanceof BodyHasLine tech) { techLine.lastGlobalNumber = blockNumber; techLine.lastHashHex = hex64; - techLine.lastThisLineNumber = ccb.thisLineNumber; - - // ВАЖНО: CREATE_CHANNEL — это root нового текстового канала: - // lineCode для этого канала = blockNumber CREATE_CHANNEL - registerTextChannelRoot(blockNumber, hash32); + techLine.lastThisLineNumber = tech.lineSeq(); + if (body instanceof CreateChannelBody) { + // CREATE_CHANNEL одновременно является root нового текстового канала. + registerTextChannelRoot(blockNumber, hash32); + } else if (!(body instanceof ForkBody)) { + throw new IllegalStateException("Unknown TECH line body: " + body.getClass()); + } return; } diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java index 43058322..e8ece631 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java @@ -331,11 +331,7 @@ public class IT_03_AddBlock_NoAuth { var ln = st.nextLineByType(ChainState.TYPE_CONNECTION); - // КОНСТРУКТОР ИЗ ТВОЕГО КОДА: - // ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, - // short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) sender.send(new ConnectionBody( - 0, // lineCode для connection линии ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, diff --git a/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java b/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java index 30de000d..4ce65a3c 100644 --- a/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java +++ b/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java @@ -132,7 +132,6 @@ public final class SeedDataPopulationHelper { ChainState state = stateFor(login); ChainState.NextLine line = state.nextLineByType(ChainState.TYPE_USER_PARAM); sender.send(new UserParamBody( - 0, line.prevLineNumber, line.prevLineHash32, line.thisLineNumber, @@ -148,7 +147,6 @@ public final class SeedDataPopulationHelper { ChainState.NextLine line = state.nextLineByType(ChainState.TYPE_CONNECTION); sender.send(new ConnectionBody( - 0, line.prevLineNumber, line.prevLineHash32, line.thisLineNumber, diff --git a/VERSION.properties b/VERSION.properties index 5fc079e5..2d646bbb 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.14.4 -server.version=1.12.2 +client.version=1.14.5 +server.version=1.12.3 diff --git a/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md b/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md index ca271ac5..34658197 100644 --- a/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md +++ b/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md @@ -10,7 +10,7 @@ - `11_TEXT_Blocks.md` — текстовые блоки (`type=1`). - `12_REACTION_Blocks.md` — реакции (`type=2`). - `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`). -- `14_USER_PARAM_Blocks.md` — пользовательские параметры (`type=4`). +- `14_USER_PROFILE_Blocks.md` — профиль пользователя (`type=4`). - `15_STATUS_ACTION_Blocks.md` — статусные действия (`type=5`). ## Быстрая карта типов @@ -19,7 +19,7 @@ - `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE. - `type=2` — REACTION: LIKE/UNLIKE. - `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции. -- `type=4` — USER_PARAM: key/value-параметры пользователя. +- `type=4` — USER_PROFILE: история полей профиля пользователя. - `type=5` — STATUS_ACTION: DONE_ONCE/LEARNED/SERVICE_PASSED/CONFIRMED/INTERESTED/STARTED/IN_STUDY/ABANDONED/COMPLETED. ## Примечание diff --git a/docs/Blockchain/01_Common_Block_Format.md b/docs/Blockchain/01_Common_Block_Format.md index dfb0ab1d..90db9755 100644 --- a/docs/Blockchain/01_Common_Block_Format.md +++ b/docs/Blockchain/01_Common_Block_Format.md @@ -77,6 +77,18 @@ App = test5590 Это временное namespace-значение для разработки. Перед реальным запуском оно будет заменено отдельным изменением протокола/кода. +### Индексный тег фиксированного потока + +Для трёх фиксированных потоков используется короткий подписанный тег: + +```text +t=tech # TECH (HEADER / CREATE_CHANNEL / FORK) +t=conn # CONNECTION +t=prof # USER_PROFILE +``` + +Для TEXT, REACTION и STATUS_ACTION тег `t` отсутствует. Сервер проверяет точное соответствие значения `t` фактическому `msg_type`. + ### Канальный тег Если блок относится к конкретному каналу, он дополнительно содержит: @@ -107,13 +119,15 @@ Slug входит в подпись DataItem и не может быть изм 1. распарсить полный ANS-104 DataItem; 2. проверить `App=test5590`; -3. проверить `c_test5590`, если тип блока требует канал; -4. проверить ANS-104 Ed25519 подпись; -5. проверить, что `owner` равен текущему blockchain public key пользователя; -6. распарсить Frame v1 и body; -7. проверить `blockNumber == last + 1`; -8. проверить `prevHash32 == lastBlockHash`; -9. записать DataItem и новое состояние атомарно в PostgreSQL. +3. проверить `t`, если тип блока требует фиксированный индексный поток; +4. проверить `c_test5590`, если тип блока требует канал; +5. проверить ANS-104 Ed25519 подпись; +6. проверить, что `owner` равен текущему blockchain public key пользователя; +7. распарсить Frame v1 и body; +8. проверить `blockNumber == last + 1`; +9. проверить `prevHash32 == lastBlockHash`; +10. проверить строгую непрерывность логической линии, если body line-based; +11. записать DataItem и новое состояние атомарно в PostgreSQL. ## Нормативное правило версий body diff --git a/docs/Blockchain/02_Blockchain_Kinds_and_Lines.md b/docs/Blockchain/02_Blockchain_Kinds_and_Lines.md index 9d0c49e6..b1df0b29 100644 --- a/docs/Blockchain/02_Blockchain_Kinds_and_Lines.md +++ b/docs/Blockchain/02_Blockchain_Kinds_and_Lines.md @@ -2,47 +2,87 @@ ## 1. Именованный блокчейн -Базовый идентификатор цепочки пользователя: +Базовый идентификатор физической цепочки пользователя: -- `blockchainName = -` -- `NNN` — реальный номер fork строго `001..999`; `000` и `1000+` недопустимы; -- пример: `alice-001` +- `blockchainName = -`; +- `NNN` — номер fork `001..999`; +- пример: `alice-001`. -Обычно это одна основная цепочка пользователя. +## 2. Логические линии внутри пользовательской цепочки -## 2. Логические линии внутри одной цепочки +Глобально все блоки пользователя идут одной последовательностью `blockNumber` и `prevHash32`. Дополнительно некоторые типы образуют собственные проверяемые линии. -Физически цепочка одна, но внутри есть независимые логические последовательности (линии), которые ведутся через поля: +Актуальные линии: -- `lineCode` -- `prevLineNumber` -- `prevLineHash32` -- `thisLineNumber` +- `TECH` — системная история: `TECH_CREATE_CHANNEL`, `TECH_FORK`; root — `HEADER`; +- `TEXT` — отдельная линия каждого канала; root — `HEADER` для канала `0` или `TECH_CREATE_CHANNEL` для пользовательского канала; +- `CONNECTION` — единая история изменений связей пользователя; root — `HEADER`; +- `USER_PROFILE` — единая история изменений профиля пользователя; root — `HEADER`. -Линии используются для: -- TECH-событий; -- каналов с текстовыми постами; -- связей и подписок; -- пользовательских параметров. +`STATUS_ACTION` в line-модель сейчас не входит. -## 3. Правила line-полей (фактическая серверная валидация) +## 3. Два формата line-prefix -Line-поля: `lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`. +### 3.1. Линии с явным `lineCode` -- Line-поля разрешены только для `msg_type`: `0`, `1`, `3`, `4`. -- Если передано хотя бы одно line-поле, должны быть переданы все 4. -- `prevLineNumber/prevLineHash32` должны указывать на существующий блок этой же цепочки. -- Для первого шага после root (`prevLineNumber == lineCode`): - - `TEXT (msg_type=1)`: `thisLineNumber = 0`; - - `TECH/CONNECTION/USER_PARAM (0/3/4)`: `thisLineNumber = 1`. -- Для обычного шага: - - `TEXT`: `thisLineNumber` допускает `same` или `+1` от предыдущего блока линии; - - `TECH/CONNECTION/USER_PARAM`: строго `+1`. +`TECH` и `TEXT` хранят: -## 4. Root-идея для каналов и подписок +```text +[4] lineCode +[4] prevLineNumber +[32] prevLineHash32 +[4] thisLineNumber +``` -Для ссылок вида follow/friend/contact принято ссылаться на корневые блоки: -- `HEADER` для базовой сущности пользователя/канала `0`; -- `CREATE_CHANNEL` для пользовательских каналов. +- для TECH `lineCode=0`; +- для канала `lineCode = global blockNumber` его root-блока (`HEADER #0` либо `TECH_CREATE_CHANNEL`). -Так ссылки остаются стабильными, даже когда в канале появляются новые сообщения. +### 3.2. Фиксированные линии + +`CONNECTION` и `USER_PROFILE` имеют по одной линии на пользователя, поэтому `lineCode` в body не хранится. Их префикс: + +```text +[4] prevLineNumber +[32] prevLineHash32 +[4] thisLineNumber +``` + +Логический `lineCode` этих линий всегда `0`. + +## 4. Что означают номера + +`prevLineNumber` — **глобальный `blockNumber` предыдущего блока этой линии**. + +`thisLineNumber` — локальный порядковый номер внутри линии: + +- первый TEXT-блок канала: `0`, далее `1,2,3...`; +- первый TECH после HEADER: `1`, далее `2,3,4...`; +- первый CONNECTION: `1`, далее `2,3,4...`; +- первый USER_PROFILE: `1`, далее `2,3,4...`. + +То есть `prevLineNumber` и `thisLineNumber` — принципиально разные числа. + +## 5. Строгая серверная валидация + +Для каждого line-блока сервер определяет **текущий фактический хвост той же линии** и требует: + +1. `prevLineNumber` равен глобальному номеру текущего хвоста (либо root для первого блока); +2. `prevLineHash32` равен hash этого хвоста/root; +3. `thisLineNumber` равен предыдущему sequence `+1` (для первого TEXT — `0`, для остальных линий — `1`); +4. root существует и имеет правильный тип. + +Ссылка просто на любой существующий блок больше не считается допустимой. Это запрещает случайные или намеренные боковые ветки линии. + +Для канала в одну линию входят все line-based события этого канала, включая обычные посты, `EDIT_POST`, `REPOST`, `CHANNEL_META` и контентные типы канала. Поэтому редактирование не создаёт отдельную боковую ветку. + +## 6. Индексные ANS-104 теги + +Фиксированные системные потоки дополнительно индексируются подписанным тегом: + +```text +t=prof # USER_PROFILE +t=conn # CONNECTION +t=tech # TECH, включая HEADER / CREATE_CHANNEL / FORK +``` + +Тег является индексом для выборочной загрузки; криптографическая непрерывность обеспечивается line-полями внутри подписанного Frame/DataItem. diff --git a/docs/Blockchain/10_TECH_Blocks.md b/docs/Blockchain/10_TECH_Blocks.md index 1f05feee..b7b0b2dd 100644 --- a/docs/Blockchain/10_TECH_Blocks.md +++ b/docs/Blockchain/10_TECH_Blocks.md @@ -1,26 +1,76 @@ # TECH блоки (`type=0`, `version=1`) -TECH-тип покрывает системные записи цепочки. +TECH-тип покрывает системные записи пользовательской цепочки. Все TECH DataItem имеют подписанный индексный тег: + +```text +t=tech +``` + +`HEADER` является root TECH-линии. Следующие line-based TECH-события (`TECH_CREATE_CHANNEL`, `TECH_FORK`) образуют одну последовательность с `lineCode=0`, ссылкой на предыдущий TECH-событийный блок и sequence `1..N`. ## Подтипы -1. `subType=0` — `HEADER_COMPAT` - - стартовый блок цепочки; - - payload: tag `SHiNE` + login владельца + `initialBlockchainKey32`; - - `initialBlockchainKey32` — public blockchain-signing key, которым был создан fork №1; это историческая точка происхождения и при последующих fork сохраняется в скопированном HEADER байт-в-байт. +### `subType=0` — `HEADER_COMPAT` -2. `subType=1` — `TECH_CREATE_CHANNEL` - - создание нового канала; - - хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`. +Стартовый блок цепочки (`block #0`). Payload содержит tag `SHiNE`, login владельца и `initialBlockchainKey32`. -3. `subType=2` — `TECH_FORK` - - первый новый блок после точной перепубликации выбранного префикса предыдущего fork новым blockchain key; - - связывает новую активную цепочку с предыдущей и фиксирует точку rollback/продолжения. +`initialBlockchainKey32` — public blockchain-signing key, которым был создан fork №1; это историческая точка происхождения. При последующих fork значение не меняется. -### `TECH_FORK` body (`version=1`) +HEADER сам не хранит line-prefix, но является root TECH-линии. + +### `subType=1` — `TECH_CREATE_CHANNEL` + +Создание нового канала. Блок одновременно: + +- является очередным шагом TECH-линии; +- является root нового канала; +- имеет `t=tech`; +- имеет `c_test5590=`. + +Начало body: + +```text +[4] lineCode = 0 +[4] prevTechBlockNumber +[32] prevTechBlockHash32 +[4] techSequence +``` + +Далее идут `channelName`, `channelDescription`, `channelType`, `channelTypeVersion` согласно формату CREATE_CHANNEL. + +### `subType=2` — `TECH_FORK` + +`TECH_FORK` фиксирует создание нового fork и является обычным следующим шагом TECH-линии. + +#### `TECH_FORK` body (`version=1`) Big-endian: +```text +[4] lineCode = 0 +[4] prevTechBlockNumber +[32] prevTechBlockHash32 +[4] techSequence + +[32] parentBlockchainKey +[4] forkPointBlockNumber +[32] forkPointBlockHash32 +[8] forkPointTimestampMs +[4] parentTipBlockNumber +[32] parentTipBlockHash32 +[8] parentTipTimestampMs +[4] discardedBlocksCount +[1] reasonCode +[2] commentUtf8Length +[N] comment UTF-8 +``` + +- `prevTechBlockNumber` — глобальный blockNumber предыдущего TECH-события; если до fork не было CREATE_CHANNEL/FORK, это `HEADER #0`; +- `prevTechBlockHash32` — hash этого блока; +- `techSequence` — предыдущий TECH sequence + 1; первый TECH-событийный блок после HEADER имеет `1`. + +Остальная fork-часть: + - `parentBlockchainKey[32]` — public key предыдущего fork; - `forkPointBlockNumber[4]` — последний блок старой цепочки, сохранённый в новом fork; - `forkPointBlockHash32[32]`; @@ -28,28 +78,27 @@ Big-endian: - `parentTipBlockNumber[4]` — tip старой цепочки на момент начала ротации; - `parentTipBlockHash32[32]`; - `parentTipTimestampMs[8]`; -- `discardedBlocksCount[4]` — `parentTipBlockNumber - forkPointBlockNumber`; +- `discardedBlocksCount[4] = parentTipBlockNumber - forkPointBlockNumber`; - `reasonCode[1]`; - `commentUtf8Length[2]`; -- `comment[N]` — произвольный комментарий пользователя, максимум 1024 UTF-8 байт. +- `comment[N]` — максимум 1024 UTF-8 байт. `reasonCode`: -- `1` — `ROUTINE_ROTATION`: обычная смена пароля/ключей, компрометация не предполагается; -- `2` — `POSSIBLE_COMPROMISE`: возможная компрометация, неизвестные записи не подтверждены; -- `3` — `CONFIRMED_COMPROMISE_ROLLBACK`: обнаружены нежелательные/чужие записи и выполнен rollback; -- `4` — `RECOVERY`: восстановление доступа recovery-механизмом. +- `1` — `ROUTINE_ROTATION`; +- `2` — `POSSIBLE_COMPROMISE`; +- `3` — `CONFIRMED_COMPROMISE_ROLLBACK`; +- `4` — `RECOVERY`. -Правила: +Текущая схема ротации перепубликует выбранный префикс `0..forkPointBlockNumber` новым blockchain key и затем добавляет `TECH_FORK`. Перепубликованные Frame сохраняются байт-в-байт; внешняя ANS-104 owner/signature меняется. Новый blockchain key в body не дублируется: он определяется owner/signature нового DataItem. -- блоки `0..forkPointBlockNumber` в новом fork должны быть точными Frame-копиями выбранного префикса предыдущей цепочки; -- `TECH_FORK` идёт сразу после этого префикса и является первым действительно новым Frame нового fork; -- если история сохранена полностью, `forkPointBlockNumber == parentTipBlockNumber` и `discardedBlocksCount == 0`; -- если сохраняется только genesis, новый fork содержит прежний `block 0`, а `block 1` является `TECH_FORK`; -- новый blockchain key в body не дублируется: он определяется owner/signature нового ANS-104 DataItem. +## Серверная проверка TECH-линии -## Назначение +Для `TECH_CREATE_CHANNEL` и `TECH_FORK` сервер требует: -- инициализация блокчейна; -- управление набором каналов пользователя; -- фиксация происхождения нового fork и причины ротации/rollback. +1. `lineCode=0`; +2. первый TECH-событийный блок ссылается на HEADER и имеет sequence `1`; +3. каждый следующий ссылается на фактический текущий хвост TECH-линии; +4. номер предыдущего блока, его hash и sequence должны совпадать точно. + +Таким образом CREATE_CHANNEL и FORK образуют единый проверяемый технический позвоночник пользователя. diff --git a/docs/Blockchain/13_CONNECTION_Blocks.md b/docs/Blockchain/13_CONNECTION_Blocks.md index faa1690c..0dfceedc 100644 --- a/docs/Blockchain/13_CONNECTION_Blocks.md +++ b/docs/Blockchain/13_CONNECTION_Blocks.md @@ -1,41 +1,68 @@ # CONNECTION блоки (`type=3`, `version=1`) -CONNECTION-тип описывает социальные связи и подписки. +CONNECTION описывает историю социальных связей и подписок пользователя. ## Подтипы -• `10/11` — `close_friend / unclose_friend` (близкий друг) -• `20/21` — `contact / uncontact` (контакт) -• `30/31` — `follow / unfollow` (подписан) -• `40/41` — `spouse / unspouse` (супруг/супруга) -• `50/51` — `parent / unparent` (родитель) -• `52/53` — `child / unchild` (ребёнок) -• `54/55` — `sibling / unsibling` (брат/сестра) -• `60/61` — `known_person / unknown_person` (знаю этого человека) -• `70/71` — `shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий) -• `74/75` — `shine_seen / shine_unseen` (мало знаком, но видел сияющим) +• `10/11` — `close_friend / unclose_friend` (близкий друг) +• `14/15` — `friend / unfriend` (друг) +• `20/21` — `contact / uncontact` (контакт) +• `30/31` — `follow / unfollow` (подписка) +• `40/41` — `spouse / unspouse` +• `50/51` — `parent / unparent` +• `52/53` — `child / unchild` +• `54/55` — `sibling / unsibling` +• `70/71` — `shine_confirmed / shine_unconfirmed` +• `74/75` — `shine_seen / shine_unseen` +• `80/81` — `official_account / unofficial_account` -## Общий формат payload +`60/61` — legacy/reserved и новым UI не создаются. -- line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`) -- target (`toLogin`, `toForkNumber`, `toBlockGlobalNumber`, `toBlockHash32`) +## Линия связей -## Бинарный target +У пользователя существует ровно одна линия `CONNECTION`. Отдельный `lineCode` в body не хранится: логический код линии всегда `0`. + +Первый CONNECTION ссылается на `HEADER #0` и имеет `connectionSequence=1`. Каждый следующий обязан ссылаться на фактический текущий хвост CONNECTION-линии и увеличивать sequence ровно на `1`. + +## Body (`version=1`) + +Big-endian: ```text -[1] toLoginLen (uint8) +[4] prevConnectionBlockNumber +[32] prevConnectionBlockHash32 +[4] connectionSequence + +[1] toLoginLen [N] toLogin UTF-8 -[2] toForkNumber (uint16, BigEndian) +[2] toForkNumber [4] toBlockGlobalNumber [32] toBlockHash32 ``` -`toForkNumber` хранит fork, который видел автор связи (`1..999`), но не участвует в identity. Значения `0` и `1000+` недопустимы. Для связи на пользователя используется **реальный HEADER hash**: `toBlockGlobalNumber=0`, `toBlockHash32=SHA-256(Frame HEADER)`. Нулевой hash запрещён. +- `prevConnectionBlockNumber` — глобальный `blockNumber` предыдущего CONNECTION-блока. +- `prevConnectionBlockHash32` — SHA-256 Frame предыдущего CONNECTION-блока. +- `connectionSequence` — порядковый номер изменения связей `1..N`. -## Правила target +Сервер проверяет номер, hash и sequence относительно текущего хвоста CONNECTION-линии. Это позволяет выгрузить только связи пользователя и проверить, что внутри выбранной истории нет пропущенного шага. -- FRIEND/CONTACT обычно указывают на `HEADER` цели (`block 0`). -- FOLLOW указывает на root канала: - - `HEADER` для канала `0`; - - `CREATE_CHANNEL` для пользовательского канала. -- Для остальных типов связи (`SPOUSE/PARENT/CHILD/SIBLING`) используется тот же target-формат. +## Target + +`toForkNumber` хранит fork, который видел автор связи (`1..999`), но не входит в логическую identity цели. `0` зарезервирован как unknown/legacy; `1000+` недопустим. + +Для связи на пользователя обычно используется его HEADER: + +```text +toBlockGlobalNumber=0 +toBlockHash32=SHA-256(Frame HEADER) +``` + +Для FOLLOW на канал target указывает на root канала: HEADER для канала `0` либо `TECH_CREATE_CHANNEL` для пользовательского канала. + +## ANS-104 тег + +Каждый CONNECTION обязан иметь подписанный тег: + +```text +t=conn +``` diff --git a/docs/Blockchain/14_USER_PARAM_Blocks.md b/docs/Blockchain/14_USER_PARAM_Blocks.md deleted file mode 100644 index b4928a7b..00000000 --- a/docs/Blockchain/14_USER_PARAM_Blocks.md +++ /dev/null @@ -1,14 +0,0 @@ -# USER_PARAM блоки (`type=4`, `version=1`) - -## Подтипы - -1. `subType=1` — `USER_PARAM_TEXT_TEXT` - - хранит line-поля + `paramKey` + `paramValue`. - -## Назначение - -- сохранение пользовательского состояния (настройки клиента, синк-метки, курсоры чтения и т.д.). - -## Практика - -Для сложных структур удобно хранить JSON-строку в `paramValue` с версией схемы. diff --git a/docs/Blockchain/14_USER_PROFILE_Blocks.md b/docs/Blockchain/14_USER_PROFILE_Blocks.md new file mode 100644 index 00000000..f87ecd12 --- /dev/null +++ b/docs/Blockchain/14_USER_PROFILE_Blocks.md @@ -0,0 +1,45 @@ +# USER_PROFILE блоки (`type=4`, `version=1`) + +`USER_PROFILE` хранит только данные, которыми пользователь описывает собственный профиль. + +> В текущем Java/JS коде историческое внутреннее имя класса/метода может оставаться `UserParamBody` / `addBlockUserParam`. На уровне протокола и документации тип называется `USER_PROFILE`. + +## Подтип + +`subType=1` — текстовое поле профиля: `paramKey + paramValue`. + +Примеры полей: имя, фамилия, `about`, аватар, web, телефон, роль аккаунта и другие свойства профиля, определённые приложением. + +## Линия профиля + +У пользователя существует ровно одна линия `USER_PROFILE`. Отдельный `lineCode` для неё не нужен и в body не хранится: логический код линии всегда `0`. + +Первый `USER_PROFILE` ссылается на `HEADER #0` и имеет `profileSequence=1`. Каждый следующий блок обязан ссылаться на фактический текущий хвост профильной линии и увеличивать sequence ровно на `1`. + +## Body (`version=1`) + +Big-endian: + +```text +[4] prevProfileBlockNumber +[32] prevProfileBlockHash32 +[4] profileSequence +[2] paramKeyLen +[N] paramKey UTF-8 +[2] paramValueLen +[M] paramValue UTF-8 +``` + +- `prevProfileBlockNumber` — **глобальный `blockNumber`** предыдущего блока профильной линии, а не локальный sequence. +- `prevProfileBlockHash32` — SHA-256 Frame предыдущего блока профильной линии. +- `profileSequence` — порядковый номер изменения профиля `1..N`. + +Сервер проверяет одновременно номер предыдущего блока, его hash и непрерывность sequence. Поэтому выборка всех блоков `USER_PROFILE` позволяет обнаружить пропуск или боковую ветку. + +## ANS-104 тег + +Каждый `USER_PROFILE` обязан иметь подписанный тег: + +```text +t=prof +``` diff --git a/docs/Blockchain/17_ANS104_Arweave_Transport.md b/docs/Blockchain/17_ANS104_Arweave_Transport.md index 259b1ea1..bd4ffbd9 100644 --- a/docs/Blockchain/17_ANS104_Arweave_Transport.md +++ b/docs/Blockchain/17_ANS104_Arweave_Transport.md @@ -14,13 +14,23 @@ App=test5590 ``` +Для фиксированных потоков: + +```text +t=tech # TECH: HEADER / CREATE_CHANNEL / FORK +t=conn # CONNECTION +t=prof # USER_PROFILE +``` + Для блоков конкретного канала дополнительно: ```text c_test5590= ``` -Теги входят в ANS-104 подпись пользователя. Старый тестовый тег `c` новым кодом не создаётся и не принимается как channel tag. +`TECH_CREATE_CHANNEL` несёт оба индекса: `t=tech` и `c_test5590=`. `STATUS_ACTION` не получает `t`-тег. + +Теги входят в ANS-104 подпись пользователя. Сервер проверяет соответствие `t` фактическому `msg_type` и отклоняет лишний/неверный `t`. Старый тестовый тег `c` новым кодом не создаётся и не принимается как channel tag. ## Publisher modes diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index f82b392a..88c81f6e 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,3 +1,14 @@ +## 2026-10-01 — Проверяемые USER_PROFILE / CONNECTION / TECH линии и signed `t` index + +- Тип `4` в документации называется `USER_PROFILE`; внутренние исторические имена классов могут сохраняться. USER_PROFILE предназначен только для данных профиля. +- `USER_PROFILE` и `CONNECTION` получили обязательную фиксированную line-схему без сериализованного `lineCode`: `prevBlockNumber + prevBlockHash32 + sequence`. Первый шаг ссылается на HEADER и имеет sequence `1`. +- `TECH_FORK` включён в общую TECH-линию вместе с `TECH_CREATE_CHANNEL`; оба используют `lineCode=0`, предыдущий TECH block/hash и `techSequence`. +- Серверная line-валидация стала строгой: новый блок обязан продолжать фактический текущий хвост той же линии и иметь sequence `+1`; произвольная ссылка на существующий блок больше не принимается. Это также устраняет боковую ветку канала после `EDIT_POST`. +- Добавлены подписанные ANS-104 индексы: `t=prof`, `t=conn`, `t=tech`. `STATUS_ACTION` не изменён. +- В отдельном `shine-solana-arweave-viewer-v3` исправлен legacy-фильтр канала `c` → канонический `c_test5590`. +- `back32Hash` и дополнительные skip-ссылки не добавлялись. +- Формат USER_PROFILE/CONNECTION/TECH_FORK несовместим с предыдущей тестовой схемой; проект находится до production и миграция старых блоков не предусмотрена. + ## 2026-09-27 — TECH_FORK v1 - Добавлен `type=0 / subType=2 / version=1` (`TECH_FORK`). diff --git a/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md b/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md index 791f37df..638aa816 100644 --- a/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md +++ b/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md @@ -9,6 +9,7 @@ - Importer всегда ищет только individual `App=test5590` DataItems. - `App=test5590-batch` больше не используется. - Channel tag: `c_test5590=` вместо `c=...`. +- Фиксированные signed indexes: `t=tech`, `t=conn`, `t=prof`. - Удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`. - Схема PostgreSQL: v25. diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index 30928277..b30c4829 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -14,7 +14,7 @@ ```text User -> создаёт Frame v1 - -> tags: App=test5590, при канале c_test5590= + -> tags: App=test5590; t=tech/conn/prof для фиксированных потоков; при канале c_test5590= -> Ed25519 подписывает ANS-104 deep-hash -> готовый DataItem -> AddBlock diff --git a/docs/Blockchain/TODO_BACK32_HASH.md b/docs/Blockchain/TODO_BACK32_HASH.md new file mode 100644 index 00000000..e1515655 --- /dev/null +++ b/docs/Blockchain/TODO_BACK32_HASH.md @@ -0,0 +1,203 @@ +# TODO — резервные ссылки `back32Hash` для SHiNE blockchain + +Статус: **TODO / не реализовано**. + +Цель этой идеи — повысить устойчивость проверки SHiNE blockchain и отдельных логических линий к ситуации, когда один или несколько DataItem временно или постоянно недоступны у конкретного Arweave gateway, CDN, сервера или другого источника. + +Это **не замена** обычным ссылкам на предыдущий блок. Основная цепочка по-прежнему строится через `prevHash32` / `prevLineHash32`. `back32...` — только дополнительная резервная ссылка назад. + +--- + +## 1. Глобальная цепочка пользователя + +В общий Frame в будущем добавить одно поле: + +```text +[32] back32Hash32 +``` + +Смысл: + +```text +back32Hash32 = HASH(global block N - 32) +``` + +То есть блок `N` дополнительно содержит hash глобального блока `N-32`. + +Пример: + +```text +block #100 + prevHash32 -> HASH(#99) + back32Hash32 -> HASH(#68) +``` + +### Для первых блоков + +Если блока `N-32` ещё не существует, поле содержит 32 нулевых байта: + +```text +#0 ... #31 -> back32Hash32 = ZERO_HASH_32 +#32 -> HASH(#0) +#33 -> HASH(#1) +... +``` + +### Номер блока отдельно НЕ хранить + +Поле вида `back32BlockNumber` не нужно. + +Номер однозначно вычисляется: + +```text +back32BlockNumber = blockNumber - 32 +``` + +Поэтому хранить ещё 4 байта номера в каждом блоке бессмысленно. + +Дополнительный размер глобального блока: **ровно +32 байта**. + +--- + +## 2. Логические линии + +Для line-based блоков в будущем добавить ещё одно поле: + +```text +[32] back32LineHash32 +``` + +Оно относится не к глобальной последовательности блоков, а к конкретной логической линии. + +Используется для: + +- `TEXT` — линия конкретного канала; +- `TECH` — техническая линия пользователя; +- `CONNECTION` — линия изменений связей; +- `USER_PROFILE` — линия изменений профиля. + +Смысл: + +```text +back32LineHash32 = HASH(line item with sequence = currentSequence - 32) +``` + +Пример для канала: + +```text +channel sequence 100 + prevLineHash32 -> HASH(channel sequence 99) + back32LineHash32 -> HASH(channel sequence 68) +``` + +Пример для профиля: + +```text +profileSequence = 50 + prevProfileBlockHash32 -> HASH(profile sequence 49) + back32LineHash32 -> HASH(profile sequence 18) +``` + +Если элемента линии с `sequence - 32` ещё нет, используется: + +```text +ZERO_HASH_32 +``` + +### Номер элемента `-32` отдельно НЕ хранить + +Он вычисляется из текущего sequence: + +```text +back32LineSequence = currentSequence - 32 +``` + +А глобальный номер нужного старого блока при необходимости определяется по уже загруженной/проиндексированной линии. + +Дополнительный размер line-based блока: + +```text ++32 байта global back32Hash32 ++32 байта back32LineHash32 += +64 байта +``` + +--- + +## 3. Зачем это нужно + +Обычная цепочка имеет только соседнюю связь: + +```text +N -> N-1 +``` + +Если блок `N-1` недоступен, проверить обычную связь следующего блока назад уже невозможно, хотя остальные данные могут быть доступны. + +С дополнительной ссылкой получается: + +```text +N -> N-1 +N -> N-32 +``` + +Поэтому даже если часть соседних блоков недоступна, можно получить дополнительную криптографическую связь с более ранней частью истории. + +То же самое для отдельной линии: + +```text +line N -> line N-1 +line N -> line N-32 +``` + +Это особенно полезно для каналов: если конкретный пост оказался недоступен или заблокирован одним источником, отсутствие этого DataItem не должно автоматически делать всю последующую историю канала полностью непроверяемой. + +--- + +## 4. Что этот механизм НЕ решает + +`back32Hash` не означает, что отсутствующий блок восстановлен или проверен. + +Если блока нет, его содержимое остаётся неизвестным. + +Механизм даёт только дополнительный проверяемый мост между доступными частями истории. + +Он также не заменяет: + +- `prevHash32`; +- `prevLineHash32`; +- sequence логической линии; +- подпись ANS-104 DataItem; +- проверку текущего хвоста линии; +- отдельный механизм доказательства того, что скачан самый последний существующий блок/элемент линии. + +--- + +## 5. Почему шаг именно 32 + +Шаг `32` выбран как простой компромисс: + +- всего 32 дополнительных байта на глобальную цепочку; +- ещё 32 байта только для line-based блоков; +- позволяет делать длинный резервный переход без сложной структуры skip-list; +- номер ссылки не надо хранить — он вычисляется арифметически; +- реализация и проверка остаются очень простыми. + +В будущем шаг можно пересмотреть только через новую версию Frame/body, если появится убедительная причина. + +--- + +## 6. План реализации в будущем + +Когда будет принято решение реализовать TODO: + +1. добавить `back32Hash32` в новую версию общего Frame; +2. при создании блока вычислять hash глобального блока `N-32`; +3. добавить `back32LineHash32` в новые версии всех line-based body; +4. вычислять его по элементу той же линии с sequence `currentSequence-32`; +5. серверу проверять эти hashes при `AddBlock`; +6. импортерам/Arweave sync/viewer использовать резервные ссылки при проверке частично доступной истории; +7. обновить тесты бинарного формата и документацию; +8. не добавлять отдельные поля номера для ссылок `-32`. + +До выполнения этих пунктов текущий канонический формат SHiNE остаётся без `back32Hash32` и `back32LineHash32`. diff --git a/docs/Черновики/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md b/docs/Черновики/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md index ca008a28..55b0bcc2 100644 --- a/docs/Черновики/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md +++ b/docs/Черновики/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md @@ -55,7 +55,7 @@ - `type=1` — `TEXT` - `type=2` — `REACTION` - `type=3` — `CONNECTION` -- `type=4` — `USER_PARAM` +- `type=4` — `USER_PROFILE` ## Верхнеуровневые типы первой итерации @@ -65,7 +65,7 @@ - `1` — `TEXT` - `2` — `REACTION` - `3` — `CONNECTION` -- `4` — `USER_PARAM` +- `4` — `USER_PROFILE` - `5` — `STATUS_ACTION` Отдельно: @@ -652,7 +652,7 @@ | 1 | TEXT | старый код, новое расширенное смысловое описание | | 2 | REACTION | старый | | 3 | CONNECTION | старый | -| 4 | USER_PARAM | старый | +| 4 | USER_PROFILE | старый | | 5 | STATUS_ACTION | первая итерация | | 6 | CHANNEL_MEMBERSHIP | вынесено в отдельный отложенный черновик | diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 5e7b6501..76ad8acb 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -37,7 +37,7 @@ import { saveEncryptedUserSecrets, saveSessionMaterial, } from './key-vault.js'; -import { createAns104DataItem } from './ans104-data-item.js'; +import { createAns104DataItem, parseAns104DataItem } from './ans104-data-item.js'; import { defaultServerWs } from '../deploy-config.js'; const BCH_SUFFIX = '001'; @@ -48,10 +48,12 @@ const MSG_TYPE_TECH = 0; const MSG_TYPE_TEXT = 1; const MSG_TYPE_REACTION = 2; const MSG_TYPE_CONNECTION = 3; +const MSG_TYPE_USER_PROFILE = 4; const MSG_TYPE_STATUS_ACTION = 5; const MSG_SUBTYPE_TECH_HEADER = 0; const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1; +const MSG_SUBTYPE_TECH_FORK = 2; const MSG_SUBTYPE_TEXT_POST = 10; const MSG_SUBTYPE_TEXT_EDIT_POST = 11; const MSG_SUBTYPE_TEXT_REPLY = 20; @@ -553,18 +555,19 @@ function makeHeaderBodyBytes({ login, initialBlockchainKey32 }) { ); } -function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) { +function makeUserParamBodyBytes({ prevProfileBlockNumber, prevProfileBlockHashHex, profileSequence, key, value }) { const keyBytes = utf8Bytes(String(key || '')); const valueBytes = utf8Bytes(String(value || '')); - const prevHashBytes = hexToBytes(prevLineHashHex); - if (!keyBytes.length || !valueBytes.length) throw new Error('Пустые key/value для блока параметра'); - if (prevHashBytes.length !== 32) throw new Error('prevLineHash должен быть 32 байта'); + const prevHashBytes = hexToBytes(prevProfileBlockHashHex); + if (!keyBytes.length || !valueBytes.length) throw new Error('Пустые key/value для блока профиля'); + if (!Number.isInteger(Number(prevProfileBlockNumber)) || Number(prevProfileBlockNumber) < 0) throw new Error('Некорректный previous profile block number'); + if (!Number.isInteger(Number(profileSequence)) || Number(profileSequence) < 1) throw new Error('Некорректный profile sequence'); + if (prevHashBytes.length !== 32) throw new Error('prevProfileBlockHash должен быть 32 байта'); return concatBytes( - int32Bytes(lineCode), - int32Bytes(prevLineNumber), + int32Bytes(prevProfileBlockNumber), prevHashBytes, - int32Bytes(thisLineNumber), + int32Bytes(profileSequence), int16Bytes(keyBytes.length), keyBytes, int16Bytes(valueBytes.length), @@ -653,15 +656,17 @@ function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) { } function makeConnectionBodyBytes({ - lineCode = 0, prevLineNumber = -1, prevLineHashHex = ZERO64, thisLineNumber = -1, + prevConnectionBlockNumber, prevConnectionBlockHashHex, connectionSequence, toLogin, toForkNumber, toBlockNumber, toBlockHashHex, }) { const loginBytes = targetLoginBytes(toLogin); const forkNumber = normalizeTargetForkNumber(toForkNumber, 'connection'); const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'connection'); const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'connection'); + if (!Number.isInteger(Number(prevConnectionBlockNumber)) || Number(prevConnectionBlockNumber) < 0) throw new Error('Некорректный previous connection block number'); + if (!Number.isInteger(Number(connectionSequence)) || Number(connectionSequence) < 1) throw new Error('Некорректный connection sequence'); return concatBytes( - int32Bytes(lineCode), int32Bytes(prevLineNumber), hexToBytes(normalizeHex32(prevLineHashHex)), int32Bytes(thisLineNumber), + int32Bytes(prevConnectionBlockNumber), hexToBytes(normalizeHex32(prevConnectionBlockHashHex)), int32Bytes(connectionSequence), int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes, ); } @@ -674,7 +679,7 @@ function targetLoginBytes(value) { function normalizeTargetForkNumber(value, actionName = 'target') { const n = Number(value); - if (!Number.isInteger(n) || n < 1 || n > 999) throw new Error(`Invalid toForkNumber for ${actionName}`); + if (!Number.isInteger(n) || n < 0 || n > 999) throw new Error(`Invalid toForkNumber for ${actionName}`); return n; } @@ -884,6 +889,51 @@ function resolveLatestLineStep(message) { return null; } +function bytesToHexLower(bytes) { + return Array.from(bytes || [], (value) => Number(value).toString(16).padStart(2, '0')).join(''); +} + +function parseStoredLineMeta(blockBytesB64, { fixedLine = false } = {}) { + const raw = base64ToBytes(String(blockBytesB64 || '')); + const frame = parseAns104DataItem(raw).data; + const bodyOffset = 56; + const minLength = fixedLine ? bodyOffset + 40 : bodyOffset + 44; + if (!(frame instanceof Uint8Array) || frame.length < minLength) throw new Error('Некорректный line-блок'); + const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); + if (fixedLine) { + return { + lineCode: 0, + prevLineNumber: view.getInt32(bodyOffset, false), + prevLineHashHex: bytesToHexLower(frame.slice(bodyOffset + 4, bodyOffset + 36)), + lineSequence: view.getInt32(bodyOffset + 36, false), + }; + } + return { + lineCode: view.getInt32(bodyOffset, false), + prevLineNumber: view.getInt32(bodyOffset + 4, false), + prevLineHashHex: bytesToHexLower(frame.slice(bodyOffset + 8, bodyOffset + 40)), + lineSequence: view.getInt32(bodyOffset + 40, false), + }; +} + +function typeIndexTagValue(msgType) { + switch (Number(msgType)) { + case MSG_TYPE_TECH: return 'tech'; + case MSG_TYPE_CONNECTION: return 'conn'; + case MSG_TYPE_USER_PROFILE: return 'prof'; + default: return ''; + } +} + +function isLineConflictError(error) { + return new Set([ + 'BAD_PREV_LINE_NUMBER', + 'BAD_PREV_LINE_HASH', + 'BAD_LINE_SEQUENCE', + 'BAD_LINE_STATE', + ]).has(String(error?.code || '').toUpperCase()); +} + function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType, msgVersion = 1, bodyBytes }) { const prevHashBytes = hexToBytes(normalizeHex32(prevBlockHashHex)); const body = bodyBytes || new Uint8Array(0); @@ -919,6 +969,8 @@ export class AuthService { this.eventListeners = new Map(); this.wsEventUnsubscribers = new Map(); this.headerHashCache = new Map(); + this.lineTailCache = new Map(); + this.lineWriteQueues = new Map(); this.writeLocks = new Map(); this.passwordKeyBundleCache = new Map(); this.passwordKeyBundleInFlight = new Map(); @@ -951,6 +1003,8 @@ export class AuthService { this.serverUrl = normalized; this.ws = new WsJsonClient(this.serverUrl); this.headerHashCache = new Map(); + this.lineTailCache = new Map(); + this.lineWriteQueues = new Map(); this.writeLocks.clear(); this.bindRegisteredEventsToCurrentWs(); if (clearSessionContext) this.clearActiveSessionContext(); @@ -1142,10 +1196,8 @@ export class AuthService { async createSessionFromImportedSecrets(login, secrets) { const cleanLogin = (login || '').trim(); if (!cleanLogin) throw new Error('В QR-коде нет логина'); - const clientKey = String(secrets?.clientKey || '').trim(); - const blockchainKey = String(secrets?.blockchainKey || '').trim(); - if (!clientKey) throw new Error('В QR-коде нет ключа доступа для входа'); - if (!blockchainKey) throw new Error('В QR-коде нет ключа блокчейна'); + const clientKey = String(secrets?.clientKey || secrets?.clientKey || '').trim(); + if (!clientKey) throw new Error('В QR-коде нет client key для входа'); const privateKey = await importPkcs8Ed25519(clientKey); const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(clientKey); @@ -1221,7 +1273,7 @@ export class AuthService { } } - async persistSelectedKeys(login, storagePwd, keyBundle) { + async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) { let currentSecrets = {}; try { const loaded = await loadEncryptedUserSecrets(login, storagePwd); @@ -1232,23 +1284,12 @@ export class AuthService { // Если контейнера ещё нет или пароль новый для этого логина — создадим новый ниже. } - const clientKey = String(keyBundle?.clientPair?.privatePkcs8B64 || '').trim(); - const blockchainKey = String(keyBundle?.blockchainPair?.privatePkcs8B64 || '').trim(); - if (!clientKey || !blockchainKey) { - throw new Error('Для входа нужно сохранить client key и blockchain key'); - } - const secrets = { ...currentSecrets, - clientKey, - blockchainKey, + clientKey: keyBundle.clientPair.privatePkcs8B64, }; - // SAWD-v1 wallet зависит от blockchain key. При смене ключа старый кэш - // нельзя переносить в новую identity: он будет лениво выведен заново. - if (String(currentSecrets?.blockchainKey || '').trim() !== blockchainKey) { - delete secrets.arweaveWallet; - } - delete secrets.rootKey; + if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64; + if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64; await saveEncryptedUserSecrets(login, storagePwd, secrets); } @@ -1773,6 +1814,8 @@ export class AuthService { } const ansTags = [{ name: 'App', value: 'test5590' }]; + const typeIndexTag = typeIndexTagValue(msgType); + if (typeIndexTag) ansTags.push({ name: 't', value: typeIndexTag }); const cleanChannelSlug = String(channelSlug || '').trim(); if (cleanChannelSlug) ansTags.push({ name: 'c_test5590', value: cleanChannelSlug }); @@ -1990,53 +2033,17 @@ export class AuthService { const selector = channel || {}; const owner = String(selector?.ownerBlockchainName || '').trim(); const root = Number(selector?.channelRootBlockNumber); - const key = `repost:${cleanLogin}:${owner}:${root}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`; + const key = `channel-line:${cleanLogin}:${owner}:${root}`; - return this.runWriteLocked(key, async () => { + return this.runLineWriteLocked(key, async () => { const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); - if (!owner || !Number.isFinite(root) || root < 0) throw new Error('Invalid channel selector'); - if (owner !== blockchainName) throw new Error('Repost is allowed only to your own channels'); - - let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || '')); - let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64); - if (rootHashHex === ZERO64 || !channelSlug) { - const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName); - const rootChannel = ownChannels.find((item) => item.rootBlockNumber === root); - if (!rootChannel) throw new Error('Channel root not found'); - if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64); - if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName); - } - - let prevLineNumber = root; - let prevLineHashHex = rootHashHex; - let thisLineNumber = 0; - try { - const latestPayload = await this.getChannelMessages({ - ownerBlockchainName: owner, - channelRootBlockNumber: root, - channelRootBlockHash: rootHashHex, - }, 1, 'desc', cleanLogin); - const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null; - const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber); - const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, ''); - const latestLineStep = resolveLatestLineStep(latestMessage); - if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) { - prevLineNumber = latestBlockNumber; - prevLineHashHex = latestBlockHash; - thisLineNumber = Number.isFinite(latestLineStep) - ? Math.max(0, latestLineStep + 1) - : 1; - } - } catch { - // fallback to root anchor - } - + const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector }); const bodyBytes = makeTextRepostBodyBytes({ - lineCode: root, - prevLineNumber, - prevLineHashHex, - thisLineNumber, + lineCode: tail.lineCode, + prevLineNumber: tail.prevLineNumber, + prevLineHashHex: tail.prevLineHashHex, + thisLineNumber: tail.thisLineNumber, toLogin: target.login, toForkNumber: target.forkNumber, toBlockNumber: target.blockNumber, @@ -2044,15 +2051,17 @@ export class AuthService { text: cleanText, }); - return this.addBlockSigned({ + const payload = await this.addBlockSigned({ login: cleanLogin, storagePwd, msgType: MSG_TYPE_TEXT, msgSubType: MSG_SUBTYPE_TEXT_REPOST, msgVersion: 1, bodyBytes, - channelSlug, + channelSlug: tail.channelSlug, }); + this.rememberChannelLineTail(blockchainName, tail.lineCode, payload, tail.thisLineNumber); + return payload; }); } @@ -2067,82 +2076,57 @@ export class AuthService { const cleanLogin = String(login || '').trim(); const cleanText = String(text || '').trim(); const target = normalizeMessageRefTarget(message, 'edit'); - const lockKey = `edit:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}:${isChannelPost ? 'post' : 'reply'}`; - return this.runWriteLocked(lockKey, async () => { - if (isChannelPost) { - const selector = channel || {}; - const ownerBlockchainName = String(selector?.ownerBlockchainName || target.blockchainName || '').trim(); - const lineCode = Number(selector?.channelRootBlockNumber); - if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) { - throw new Error('Invalid channel selector for edit'); - } - - let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || '')); - let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64); - if (rootHashHex === ZERO64 || !channelSlug) { - const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, ownerBlockchainName); - const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode); - if (rootChannel) { - if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64); - if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName); - } - } - - let prevLineNumber = lineCode; - let prevLineHashHex = rootHashHex; - let thisLineNumber = 1; - try { - const latestPayload = await this.getChannelMessages({ + if (isChannelPost) { + const selector = channel || {}; + const ownerBlockchainName = String(selector?.ownerBlockchainName || target.blockchainName || '').trim(); + const lineCode = Number(selector?.channelRootBlockNumber); + if (!ownerBlockchainName || !Number.isInteger(lineCode) || lineCode < 0) { + throw new Error('Invalid channel selector for edit'); + } + const lockKey = `channel-line:${cleanLogin}:${ownerBlockchainName}:${lineCode}`; + return this.runLineWriteLocked(lockKey, async () => { + const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); + const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); + const tail = await this.resolveChannelLineTail({ + login: cleanLogin, + blockchainName, + channel: { + ...selector, ownerBlockchainName, channelRootBlockNumber: lineCode, - channelRootBlockHash: rootHashHex, - }, 1, 'desc', cleanLogin); - const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null; - const latestVersions = Array.isArray(latestMessage?.versions) ? latestMessage.versions : []; - const latestVersion = latestVersions[latestVersions.length - 1] || null; - const latestBlockNumber = Number(latestVersion?.blockNumber ?? latestMessage?.messageRef?.blockNumber); - const latestBlockHash = normalizeHex32(latestVersion?.blockHash ?? latestMessage?.messageRef?.blockHash, ''); - const latestLineStep = resolveLatestLineStep(latestMessage); - if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) { - prevLineNumber = latestBlockNumber; - prevLineHashHex = latestBlockHash; - thisLineNumber = Number.isFinite(latestLineStep) - ? Math.max(0, latestLineStep) - : 1; - } - } catch { - // fallback to root anchor - } - + }, + }); const bodyBytes = makeTextEditPostBodyBytes({ - lineCode, - prevLineNumber, - prevLineHashHex, - thisLineNumber, - toLogin: target.login, + lineCode: tail.lineCode, + prevLineNumber: tail.prevLineNumber, + prevLineHashHex: tail.prevLineHashHex, + thisLineNumber: tail.thisLineNumber, toBlockNumber: target.blockNumber, toBlockHashHex: target.blockHash, text: cleanText, }); - - return this.addBlockSigned({ + const payload = await this.addBlockSigned({ login: cleanLogin, storagePwd, msgType: MSG_TYPE_TEXT, msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST, msgVersion: 1, bodyBytes, - channelSlug, + channelSlug: tail.channelSlug, }); - } + this.rememberChannelLineTail(blockchainName, tail.lineCode, payload, tail.thisLineNumber); + return payload; + }); + } + const lockKey = `edit-reply:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`; + return this.runWriteLocked(lockKey, async () => { const bodyBytes = makeTextEditReplyBodyBytes({ toBlockNumber: target.blockNumber, toBlockHashHex: target.blockHash, text: cleanText, }); - return this.addBlockSigned({ login: cleanLogin, storagePwd, @@ -2196,35 +2180,21 @@ export class AuthService { throw new Error('Invalid target block number'); } const seedHash = normalizeHex32(targetBlockHashHex, ZERO64); - const key = `${unfollow ? 'unfollow-channel' : 'follow-channel'}:${cleanLogin}:${cleanTargetBch}:${cleanTargetBlockNumber}:${seedHash}`; + let targetHashHex = seedHash; + if (targetHashHex === ZERO64) { + targetHashHex = cleanTargetBlockNumber === 0 + ? await this.resolveHeaderHashForBlockchain(cleanTargetBch) + : await this.getBlockHashByNumber(cleanTargetBch, cleanTargetBlockNumber); + } - return this.runWriteLocked(key, async () => { - let targetHashHex = seedHash; - if (targetHashHex === ZERO64) { - targetHashHex = cleanTargetBlockNumber === 0 - ? await this.resolveHeaderHashForBlockchain(cleanTargetBch) - : await this.getBlockHashByNumber(cleanTargetBch, cleanTargetBlockNumber); - } - - const bodyBytes = makeConnectionBodyBytes({ - lineCode: 0, - prevLineNumber: -1, - prevLineHashHex: ZERO64, - thisLineNumber: -1, - toLogin: cleanTargetLogin, - toForkNumber: forkNumberFromBlockchainNameValue(cleanTargetBch), - toBlockNumber: cleanTargetBlockNumber, - toBlockHashHex: targetHashHex, - }); - - return this.addBlockSigned({ - login: cleanLogin, - storagePwd, - msgType: MSG_TYPE_CONNECTION, - msgSubType: unfollow ? MSG_SUBTYPE_CONNECTION_UNFOLLOW : MSG_SUBTYPE_CONNECTION_FOLLOW, - msgVersion: 1, - bodyBytes, - }); + return this.addBlockConnectionTarget({ + login: cleanLogin, + storagePwd, + subType: unfollow ? MSG_SUBTYPE_CONNECTION_UNFOLLOW : MSG_SUBTYPE_CONNECTION_FOLLOW, + toLogin: cleanTargetLogin, + toForkNumber: forkNumberFromBlockchainNameValue(cleanTargetBch), + toBlockNumber: cleanTargetBlockNumber, + toBlockHashHex: targetHashHex, }); } @@ -2270,6 +2240,123 @@ export class AuthService { return headerHash; } + async getMyBlockchainPage({ beforeBlock = null, limit = 100, includeBlockBytes = true } = {}) { + const response = await this.ws.request('GetMyBlockchain', { beforeBlock, limit, includeBlockBytes }); + if (response.status !== 200) throw opError('GetMyBlockchain', response); + return response.payload || {}; + } + + async findLatestOwnLineBlock({ + blockchainName, + msgType, + subTypes = null, + lineCode = null, + fixedLine = false, + stopAtBlock = 0, + }) { + const cleanBch = String(blockchainName || '').trim(); + if (!cleanBch) throw new Error('Missing blockchainName for line lookup'); + const subtypeSet = subTypes == null ? null : new Set(Array.from(subTypes, (value) => Number(value))); + const minBlock = Math.max(0, Number(stopAtBlock) || 0); + let beforeBlock = null; + + for (;;) { + const page = await this.getMyBlockchainPage({ beforeBlock, limit: 100, includeBlockBytes: true }); + const pageBch = String(page?.blockchainName || '').trim(); + if (pageBch && pageBch !== cleanBch) throw new Error('GetMyBlockchain returned another blockchain'); + const blocks = Array.isArray(page?.blocks) ? page.blocks : []; + for (const block of blocks) { + const blockNumber = Number(block?.blockNumber); + if (!Number.isInteger(blockNumber) || blockNumber <= minBlock) continue; + if (Number(block?.msgType) !== Number(msgType)) continue; + if (subtypeSet && !subtypeSet.has(Number(block?.msgSubType))) continue; + if (!block?.blockBytesB64) continue; + const meta = parseStoredLineMeta(block.blockBytesB64, { fixedLine }); + if (lineCode != null && Number(meta.lineCode) !== Number(lineCode)) continue; + return { + blockNumber, + blockHash: normalizeHex32(block?.blockHash, ZERO64), + lineSequence: Number(meta.lineSequence), + lineCode: Number(meta.lineCode), + }; + } + const next = page?.nextBeforeBlock; + if (next == null || !Number.isInteger(Number(next)) || Number(next) <= minBlock) return null; + beforeBlock = Number(next); + } + } + + async resolveFixedLineTail({ blockchainName, kind }) { + const cleanBch = String(blockchainName || '').trim(); + const kindKey = String(kind || '').trim().toLowerCase(); + const config = kindKey === 'profile' + ? { msgType: MSG_TYPE_USER_PROFILE, tag: 'prof' } + : kindKey === 'connections' + ? { msgType: MSG_TYPE_CONNECTION, tag: 'conn' } + : kindKey === 'tech' + ? { msgType: MSG_TYPE_TECH, tag: 'tech', subTypes: [MSG_SUBTYPE_TECH_CREATE_CHANNEL, MSG_SUBTYPE_TECH_FORK] } + : null; + if (!config) throw new Error(`Unknown fixed line kind: ${kind}`); + + const cacheKey = `${cleanBch}|${kindKey}`; + const cached = this.lineTailCache.get(cacheKey); + if (cached) return { ...cached }; + + const latest = await this.findLatestOwnLineBlock({ + blockchainName: cleanBch, + msgType: config.msgType, + subTypes: config.subTypes || null, + lineCode: kindKey === 'tech' ? 0 : null, + fixedLine: kindKey !== 'tech', + stopAtBlock: 0, + }); + if (latest) { + const result = { + prevLineNumber: latest.blockNumber, + prevLineHashHex: latest.blockHash, + thisLineNumber: latest.lineSequence + 1, + }; + this.lineTailCache.set(cacheKey, result); + return { ...result }; + } + + const headerHash = await this.resolveHeaderHashForBlockchain(cleanBch); + if (headerHash === ZERO64) throw new Error('HEADER блокчейна не найден'); + const result = { prevLineNumber: 0, prevLineHashHex: headerHash, thisLineNumber: 1 }; + this.lineTailCache.set(cacheKey, result); + return { ...result }; + } + + rememberLineTail(blockchainName, kind, payload, currentSequence) { + const cleanBch = String(blockchainName || '').trim(); + const acceptedNumber = Number(payload?.serverLastGlobalNumber); + const acceptedHash = normalizeHex32(payload?.serverLastGlobalHash, ZERO64); + if (!cleanBch || !Number.isInteger(acceptedNumber) || acceptedNumber < 0 || acceptedHash === ZERO64) return; + this.lineTailCache.set(`${cleanBch}|${String(kind || '').trim().toLowerCase()}`, { + prevLineNumber: acceptedNumber, + prevLineHashHex: acceptedHash, + thisLineNumber: Number(currentSequence) + 1, + }); + } + + async runLineWriteLocked(lockKey, buildAndSend) { + const key = String(lockKey || '').trim() || 'line-write'; + const previous = this.lineWriteQueues.get(key) || Promise.resolve(); + const task = previous.catch(() => {}).then(async () => { + try { + return await buildAndSend(); + } catch (error) { + if (!isLineConflictError(error)) throw error; + this.lineTailCache.clear(); + return buildAndSend(); + } + }); + this.lineWriteQueues.set(key, task); + return task.finally(() => { + if (this.lineWriteQueues.get(key) === task) this.lineWriteQueues.delete(key); + }); + } + async listOwnChannelsForBlockchain(login, blockchainName) { const feed = await this.listSubscriptionsFeed(login, 500); const own = feed?.ownedChannels || []; @@ -2314,34 +2401,11 @@ export class AuthService { : normalizeChannelDescription(channelDescription); const channelSlug = toCanonicalChannelSlug(cleanChannelName); const typeVersion = Number(channelTypeVersion); - const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`; - return this.runWriteLocked(key, async () => { + const key = `tech-line:${cleanLogin}`; + return this.runLineWriteLocked(key, async () => { const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); - const userLastGlobalNumber = Number(user?.serverLastGlobalNumber); - const userLastGlobalHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64); - - const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName); - const createdChannels = ownChannels - .filter((item) => item.rootBlockNumber > 0) - .sort((a, b) => a.rootBlockNumber - b.rootBlockNumber); - - let prevLineNumber = 0; - let prevLineHashHex = ( - Number.isFinite(userLastGlobalNumber) && - userLastGlobalNumber === 0 && - userLastGlobalHash !== ZERO64 - ) - ? userLastGlobalHash - : await this.resolveHeaderHashForBlockchain(blockchainName); - let thisLineNumber = 1; - - if (createdChannels.length > 0) { - const last = createdChannels[createdChannels.length - 1]; - prevLineNumber = last.rootBlockNumber; - prevLineHashHex = normalizeHex32(last.rootBlockHash, ZERO64); - thisLineNumber = createdChannels.length + 1; - } + const tail = await this.resolveFixedLineTail({ blockchainName, kind: 'tech' }); const payload = await this.addBlockSigned({ login: cleanLogin, @@ -2352,15 +2416,16 @@ export class AuthService { channelSlug, bodyBytes: makeCreateChannelBodyBytes({ lineCode: 0, - prevLineNumber, - prevLineHashHex, - thisLineNumber, + prevLineNumber: tail.prevLineNumber, + prevLineHashHex: tail.prevLineHashHex, + thisLineNumber: tail.thisLineNumber, channelName: cleanChannelName, channelDescription: cleanChannelDescription, channelType: typeCode, channelTypeVersion: typeVersion, }), }); + this.rememberLineTail(blockchainName, 'tech', payload, tail.thisLineNumber); const selector = { ownerBlockchainName: blockchainName, @@ -2381,7 +2446,7 @@ export class AuthService { const selector = channel || {}; const ownerBlockchainName = String(selector?.ownerBlockchainName || '').trim(); const lineCode = Number(selector?.channelRootBlockNumber); - if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) { + if (!ownerBlockchainName || !Number.isInteger(lineCode) || lineCode < 0) { throw new Error('Invalid channel selector'); } if (ownerBlockchainName !== blockchainName) { @@ -2398,52 +2463,70 @@ export class AuthService { if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName); } if (!channelSlug) throw new Error('Cannot resolve canonical channel slug'); + if (rootHashHex === ZERO64) throw new Error('Cannot resolve channel root hash'); - let prevLineNumber = lineCode; - let prevLineHashHex = rootHashHex; - let thisLineNumber = 0; - try { - const latestPayload = await this.getChannelMessages({ + const cacheKey = `${blockchainName}|channel:${lineCode}`; + const cached = this.lineTailCache.get(cacheKey); + if (cached) { + return { ownerBlockchainName, - channelRootBlockNumber: lineCode, - channelRootBlockHash: rootHashHex, - }, 1, 'desc', login); - const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null; - const latestMeta = (Array.isArray(latestPayload?.metaEvents) ? latestPayload.metaEvents : []) - .slice() - .sort((a, b) => Number(b?.messageRef?.blockNumber || -1) - Number(a?.messageRef?.blockNumber || -1))[0] || null; - const latestMessageBlock = Number(latestMessage?.messageRef?.blockNumber); - const latestMetaBlock = Number(latestMeta?.messageRef?.blockNumber); - const latest = Number.isFinite(latestMetaBlock) && (!Number.isFinite(latestMessageBlock) || latestMetaBlock > latestMessageBlock) - ? latestMeta - : latestMessage; - const latestBlockNumber = Number(latest?.messageRef?.blockNumber); - const latestBlockHash = normalizeHex32(latest?.messageRef?.blockHash, ''); - const latestLineStep = resolveLatestLineStep(latest); - if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) { - prevLineNumber = latestBlockNumber; - prevLineHashHex = latestBlockHash; - thisLineNumber = latest?.kind === 'created' - ? 0 - : Number.isFinite(latestLineStep) - ? Math.max(0, latestLineStep + 1) - : (latestBlockNumber === lineCode ? 0 : 1); - } - } catch { - // fallback to root anchor + lineCode, + rootHashHex, + channelSlug, + ...cached, + }; } + const latest = await this.findLatestOwnLineBlock({ + blockchainName, + msgType: MSG_TYPE_TEXT, + subTypes: [ + MSG_SUBTYPE_TEXT_POST, + MSG_SUBTYPE_TEXT_EDIT_POST, + MSG_SUBTYPE_TEXT_REPOST, + MSG_SUBTYPE_TEXT_CHANNEL_META, + MSG_SUBTYPE_TEXT_ENTRYPOINT, + MSG_SUBTYPE_TEXT_EXERCISE, + MSG_SUBTYPE_TEXT_SERVICE, + MSG_SUBTYPE_TEXT_COURSE, + ], + lineCode, + fixedLine: false, + stopAtBlock: lineCode, + }); + + const lineTail = latest + ? { + prevLineNumber: latest.blockNumber, + prevLineHashHex: latest.blockHash, + thisLineNumber: latest.lineSequence + 1, + } + : { + prevLineNumber: lineCode, + prevLineHashHex: rootHashHex, + thisLineNumber: 0, + }; + this.lineTailCache.set(cacheKey, lineTail); return { ownerBlockchainName, lineCode, rootHashHex, - prevLineNumber, - prevLineHashHex, - thisLineNumber, channelSlug, + ...lineTail, }; } + rememberChannelLineTail(blockchainName, lineCode, payload, currentSequence) { + const acceptedNumber = Number(payload?.serverLastGlobalNumber); + const acceptedHash = normalizeHex32(payload?.serverLastGlobalHash, ZERO64); + if (!Number.isInteger(acceptedNumber) || acceptedNumber < 0 || acceptedHash === ZERO64) return; + this.lineTailCache.set(`${String(blockchainName || '').trim()}|channel:${Number(lineCode)}`, { + prevLineNumber: acceptedNumber, + prevLineHashHex: acceptedHash, + thisLineNumber: Number(currentSequence) + 1, + }); + } + async addBlockTextPost({ login, channel, text, storagePwd, msgSubType = MSG_SUBTYPE_TEXT_POST }) { const cleanLogin = (login || '').trim(); if (!cleanLogin) throw new Error('Missing login'); @@ -2462,9 +2545,9 @@ export class AuthService { const selector = channel || {}; const owner = String(selector?.ownerBlockchainName || '').trim(); const root = Number(selector?.channelRootBlockNumber); - const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanSubType}:${cleanText}`; + const key = `channel-line:${cleanLogin}:${owner}:${root}`; - return this.runWriteLocked(key, async () => { + return this.runLineWriteLocked(key, async () => { const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); @@ -2487,6 +2570,7 @@ export class AuthService { bodyBytes, channelSlug: tail.channelSlug, }); + this.rememberChannelLineTail(blockchainName, tail.lineCode, payload, tail.thisLineNumber); return { ...payload, @@ -2506,9 +2590,9 @@ export class AuthService { const owner = String(selector?.ownerBlockchainName || '').trim(); const root = Number(selector?.channelRootBlockNumber); const metaText = composeChannelMetaText({ title, description, avatar }); - const key = `channel-meta:${cleanLogin}:${owner}:${root}:${metaText}`; + const key = `channel-line:${cleanLogin}:${owner}:${root}`; - return this.runWriteLocked(key, async () => { + return this.runLineWriteLocked(key, async () => { const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector }); @@ -2529,6 +2613,7 @@ export class AuthService { bodyBytes, channelSlug: tail.channelSlug, }); + this.rememberChannelLineTail(blockchainName, tail.lineCode, payload, tail.thisLineNumber); return { ...payload, @@ -3161,21 +3246,65 @@ export class AuthService { if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.'); if (!cleanValue) throw new Error('Значение параметра не может быть пустым.'); if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.'); - const bodyBytes = makeUserParamBodyBytes({ - lineCode: 0, - prevLineNumber: -1, - prevLineHashHex: ZERO_HASH_HEX, - thisLineNumber: -1, - key: cleanParam, - value: cleanValue, + + return this.runLineWriteLocked(`profile-line:${cleanLogin}`, async () => { + const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); + const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); + const tail = await this.resolveFixedLineTail({ blockchainName, kind: 'profile' }); + const bodyBytes = makeUserParamBodyBytes({ + prevProfileBlockNumber: tail.prevLineNumber, + prevProfileBlockHashHex: tail.prevLineHashHex, + profileSequence: tail.thisLineNumber, + key: cleanParam, + value: cleanValue, + }); + const payload = await this.addBlockSigned({ + login: cleanLogin, + storagePwd, + msgType: MSG_TYPE_USER_PROFILE, + msgSubType: 1, + msgVersion: 1, + bodyBytes, + }); + this.rememberLineTail(blockchainName, 'profile', payload, tail.thisLineNumber); + return payload; }); - return this.addBlockSigned({ - login: cleanLogin, - storagePwd, - msgType: 4, - msgSubType: 1, - msgVersion: 1, - bodyBytes, + } + + async addBlockConnectionTarget({ + login, + storagePwd, + subType, + toLogin, + toForkNumber, + toBlockNumber, + toBlockHashHex, + }) { + const cleanLogin = String(login || '').trim(); + if (!cleanLogin) throw new Error('Missing login for CONNECTION'); + return this.runLineWriteLocked(`connections-line:${cleanLogin}`, async () => { + const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); + const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); + const tail = await this.resolveFixedLineTail({ blockchainName, kind: 'connections' }); + const bodyBytes = makeConnectionBodyBytes({ + prevConnectionBlockNumber: tail.prevLineNumber, + prevConnectionBlockHashHex: tail.prevLineHashHex, + connectionSequence: tail.thisLineNumber, + toLogin, + toForkNumber, + toBlockNumber, + toBlockHashHex, + }); + const payload = await this.addBlockSigned({ + login: cleanLogin, + storagePwd, + msgType: MSG_TYPE_CONNECTION, + msgSubType: Number(subType), + msgVersion: 1, + bodyBytes, + }); + this.rememberLineTail(blockchainName, 'connections', payload, tail.thisLineNumber); + return payload; }); } @@ -3190,9 +3319,6 @@ export class AuthService { throw new Error('Нельзя создать связь на самого себя.'); } - const user = await this.getUser(cleanLogin); - if (user?.exists === false) throw new Error('Текущий пользователь не найден.'); - const targetUser = await this.getUser(cleanToLogin); if (!targetUser?.exists) throw new Error('Пользователь цели не найден.'); const targetBlockchainName = String(targetUser.blockchainName || '').trim(); @@ -3200,24 +3326,15 @@ export class AuthService { if (!targetForkNumber) throw new Error('Не удалось определить fork пользователя цели.'); const targetHeaderHash = await this.resolveHeaderHashForBlockchain(targetBlockchainName); if (targetHeaderHash === ZERO64) throw new Error('HEADER пользователя цели не найден.'); - const bodyBytes = makeConnectionBodyBytes({ - lineCode: 0, - prevLineNumber: -1, - prevLineHashHex: ZERO_HASH_HEX, - thisLineNumber: -1, + return this.addBlockConnectionTarget({ + login: cleanLogin, + storagePwd, + subType: cleanSubType, toLogin: cleanToLogin, toForkNumber: targetForkNumber, toBlockNumber: 0, toBlockHashHex: targetHeaderHash, }); - return this.addBlockSigned({ - login: cleanLogin, - storagePwd, - msgType: 3, - msgSubType: cleanSubType, - msgVersion: 1, - bodyBytes, - }); } diff --git a/shine-UI/js/services/key-rotation-service.js b/shine-UI/js/services/key-rotation-service.js index 0041c896..8a301a49 100644 --- a/shine-UI/js/services/key-rotation-service.js +++ b/shine-UI/js/services/key-rotation-service.js @@ -37,12 +37,30 @@ function readFrameTimestampMs(frame) { const seconds = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getBigInt64(42, false); return Number(seconds * 1000n); } -function makeForkBody({ oldBlockchainKeyB64, forkPointBlock, forkPointHash, forkPointTimestampMs, parentTipBlock, parentTipHash, parentTipTimestampMs, reasonCode, comment }) { +function readFrameType(frame) { + if (!(frame instanceof Uint8Array) || frame.length < 56) throw new Error('Некорректный SHiNE Frame'); + const view=new DataView(frame.buffer,frame.byteOffset,frame.byteLength); + return { type:view.getUint16(50,false), subType:view.getUint16(52,false) }; +} +function readExplicitLinePrefix(frame) { + if (!(frame instanceof Uint8Array) || frame.length < 100) throw new Error('Некорректный line Frame'); + const view=new DataView(frame.buffer,frame.byteOffset,frame.byteLength); + return { + lineCode:view.getInt32(56,false), + prevLineNumber:view.getInt32(60,false), + prevLineHash:bytesToHex(frame.slice(64,96)), + lineSequence:view.getInt32(96,false), + }; +} +function makeForkBody({ lineCode=0, prevLineNumber, prevLineHash, lineSequence, oldBlockchainKeyB64, forkPointBlock, forkPointHash, forkPointTimestampMs, parentTipBlock, parentTipHash, parentTipTimestampMs, reasonCode, comment }) { const oldKey=base64ToBytes(oldBlockchainKeyB64); if(oldKey.length!==32) throw new Error('Некорректный старый blockchain key'); const c=TE.encode(normalizeComment(comment)); if(c.length>1024) throw new Error('Комментарий больше 1024 UTF-8 байт'); const discarded=Number(parentTipBlock)-Number(forkPointBlock); if(discarded<0) throw new Error('Некорректная точка fork'); - return concat(oldKey,be32(forkPointBlock),hexToBytes(forkPointHash),be64(forkPointTimestampMs),be32(parentTipBlock),hexToBytes(parentTipHash),be64(parentTipTimestampMs),be32(discarded),Uint8Array.of(Number(reasonCode)),be16(c.length),c); + if(Number(lineCode)!==0 || !Number.isInteger(Number(prevLineNumber)) || Number(prevLineNumber)<0 || !Number.isInteger(Number(lineSequence)) || Number(lineSequence)<1) { + throw new Error('Некорректная TECH line для FORK'); + } + return concat(be32(lineCode),be32(prevLineNumber),hexToBytes(prevLineHash),be32(lineSequence),oldKey,be32(forkPointBlock),hexToBytes(forkPointHash),be64(forkPointTimestampMs),be32(parentTipBlock),hexToBytes(parentTipHash),be64(parentTipTimestampMs),be32(discarded),Uint8Array.of(Number(reasonCode)),be16(c.length),c); } function makeFrame({ prevHash, blockNumber, type, subType, version=1, body, timestampMs=Date.now() }) { const bodyBytes=body || new Uint8Array(0); @@ -75,6 +93,24 @@ export class KeyRotationClient { return assertOk(await this.auth.ws.request('KeyRotationAddBlock', { blockNumber, prevBlockHash, blockBytesB64 }, 30000), 'KeyRotationAddBlock'); } + async #resolveTechLineTail(source, cutoff) { + for(let n=Number(cutoff);n>0;n--) { + const block=await this.getBlock(source,n); + const parsed=parseAns104DataItem(base64ToBytes(block.blockBytesB64)); + const kind=readFrameType(parsed.data); + if(kind.type!==0 || (kind.subType!==1 && kind.subType!==TECH_FORK_SUBTYPE)) continue; + const line=readExplicitLinePrefix(parsed.data); + if(line.lineCode!==0 || line.lineSequence<1) throw new Error(`Некорректная TECH line в блоке #${n}`); + return { + prevLineNumber:n, + prevLineHash:String(block.blockHash||''), + lineSequence:line.lineSequence+1, + }; + } + const header=await this.getBlock(source,0); + return { prevLineNumber:0, prevLineHash:String(header.blockHash||''), lineSequence:1 }; + } + async copyCandidateChain({ rotation, newBundle, onProgress=()=>{} }) { const cutoff=Number(rotation.forkFromBlock); const source=String(rotation.sourceBlockchainName||''); if(!source || !Number.isInteger(cutoff) || cutoff<0) throw new Error('Некорректное состояние ротации'); @@ -100,7 +136,12 @@ export class KeyRotationClient { const tipSource=tipBlock===cutoff ? forkSource : await this.getBlock(source,tipBlock); const forkParsed=parseAns104DataItem(base64ToBytes(forkSource.blockBytesB64)); const tipParsed=parseAns104DataItem(base64ToBytes(tipSource.blockBytesB64)); + const techTail=await this.#resolveTechLineTail(source,cutoff); const forkBody=makeForkBody({ + lineCode:0, + prevLineNumber:techTail.prevLineNumber, + prevLineHash:techTail.prevLineHash, + lineSequence:techTail.lineSequence, oldBlockchainKeyB64:rotation.oldBlockchainKey, forkPointBlock:cutoff, forkPointHash:rotation.forkFromHash, @@ -111,9 +152,9 @@ export class KeyRotationClient { reasonCode:rotation.reasonCode, comment:rotation.comment, }); - const techTimestampMs=Number(rotation.createdAtMs)||Number(parentTipTimestampMs)+1000; + const techTimestampMs=Number(rotation.createdAtMs)||readFrameTimestampMs(tipParsed.data)+1000; const techFrame=makeFrame({ prevHash:rotation.forkFromHash, blockNumber:cutoff+1, type:0, subType:TECH_FORK_SUBTYPE, version:1, body:forkBody, timestampMs:techTimestampMs }); - const techRaw=await createAns104DataItem({ owner32, privateKey, data:techFrame, tags:[{name:'App',value:'test5590'}] }); + const techRaw=await createAns104DataItem({ owner32, privateKey, data:techFrame, tags:[{name:'App',value:'test5590'},{name:'t',value:'tech'}] }); await this.addCandidate({ blockNumber:cutoff+1, prevBlockHash:rotation.forkFromHash, blockBytesB64:bytesToBase64(techRaw) }); onProgress({ phase:'copy', current:total, total }); } @@ -160,17 +201,12 @@ export class KeyRotationClient { } const state=await this.notifyPdaRotation(signature); if(storagePwd) { - await updateEncryptedUserSecrets(login, storagePwd, (current)=>{ - const next={ - ...(current||{}), - blockchainKey:newBundle.blockchainPair.privatePkcs8B64, - clientKey:newBundle.clientPair.privatePkcs8B64, - }; - // Arweave SAWD-v1 привязан к blockchain key: после ротации старый JWK-cache недействителен. - delete next.arweaveWallet; - delete next.rootKey; - return next; - }); + await updateEncryptedUserSecrets(login, storagePwd, (current)=>({ + ...(current||{}), + rootKey:newBundle.rootPair.privatePkcs8B64, + blockchainKey:newBundle.blockchainPair.privatePkcs8B64, + clientKey:newBundle.clientPair.privatePkcs8B64, + })); } return state; } diff --git a/shine-solana-arweave-viewer-v3/index.html b/shine-solana-arweave-viewer-v3/index.html index e168715c..c749aeec 100644 --- a/shine-solana-arweave-viewer-v3/index.html +++ b/shine-solana-arweave-viewer-v3/index.html @@ -2430,7 +2430,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x const gateway = baseUrl(gatewayBaseUrl); const ownerAddress = await arweaveOwnerAddressFromBlockchainKey(blockchainKey); const tags = [{ name: 'App', values: [SHINE_ARWEAVE_APP_TAG] }]; - if (channelName) tags.push({ name: 'c', values: [channelName] }); + if (channelName) tags.push({ name: 'c_test5590', values: [channelName] }); const query = `query($owners:[String!],$tags:[TagFilter!],$after:String){transactions(first:100,after:$after,owners:$owners,tags:$tags,sort:HEIGHT_DESC){pageInfo{hasNextPage}edges{cursor node{id owner{address key} block{height timestamp} bundledIn{id} tags{name value}}}}}`; let after = null; let page = 0;