Добавить линии связей и параметров пользователя

This commit is contained in:
AidarKC
2026-10-02 00:33:13 +03:00
parent 8d22602761
commit e101e5c5f4
33 changed files with 1293 additions and 568 deletions
@@ -13,5 +13,5 @@
// public static final short TEXT = 1; // сообщения да надо // public static final short TEXT = 1; // сообщения да надо
// public static final short REACTION = 2; // реакции не надо // public static final short REACTION = 2; // реакции не надо
// public static final short CONNECTION = 3; // связи (friend/contact/follow) да надо // public static final short CONNECTION = 3; // связи (friend/contact/follow) да надо
// public static final short USER_PARAM = 4; // параметры профиля да надо // public static final short USER_PROFILE= 4; // профиль пользователя да надо
//} //}
@@ -1,21 +1,23 @@
package blockchain.body; package blockchain.body;
/** /**
* BodyHasLine — для типов, которые имеют линейные поля в body. * BodyHasLine — типы блоков, которые входят в проверяемую логическую линию.
* *
* Line-prefix (BigEndian) в НАЧАЛЕ bodyBytes: * Общая семантика:
* [4] lineCode код линии (root-идентификатор): * prevLineBlockGlobalNumber — GLOBAL blockNumber предыдущего блока этой линии;
* - 0 для дефолтной линии/канала "0" (root = HEADER, blockNumber=0) * prevLineBlockHash32 — hash32 предыдущего блока этой линии;
* - для канала "X": blockNumber root-блока канала (CREATE_CHANNEL) * lineSeq — порядковый номер шага линии.
* *
* [4] prevLineBlockGlobalNumber глобальный номер предыдущего блока в этой линии * lineCode — логический идентификатор линии:
* [32] prevLineBlockHash32 hash32 предыдущего блока в этой линии * - 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 явно.
* *
* Важно: * Полная проверка хвоста линии, prev hash и lineSeq выполняется сервером при AddBlock.
* - Проверка связности линии (prevLineBlockGlobalNumber ↔ prevLineBlockHash32) и корректности lineSeq
* выполняется на сервере/в БД при вставке (а не в body.check()).
*/ */
public interface BodyHasLine { public interface BodyHasLine {
@@ -7,7 +7,10 @@ import java.nio.ByteOrder;
import java.util.Arrays; import java.util.Arrays;
import java.util.Objects; 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 final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
public static final short TYPE = 3; public static final short TYPE = 3;
public static final short VER = 1; 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 subType;
public final short version; public final short version;
public final int lineCode; public final int prevConnectionBlockNumber;
public final int prevLineNumber; public final byte[] prevConnectionBlockHash32;
public final byte[] prevLineHash32; public final int connectionSequence;
public final int thisLineNumber;
public final String toLogin; public final String toLogin;
public final int toForkNumber; public final int toForkNumber;
public final int toBlockGlobalNumber; public final int toBlockGlobalNumber;
@@ -26,47 +28,59 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
public ConnectionBody(short subType, short version, byte[] bodyBytes) { public ConnectionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null"); 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"); if ((version & 0xFFFF) != 1 || !isValidSubType(subType)) throw new IllegalArgumentException("Bad Connection header");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
BodyTargetSupport.ensureMin(bb, 4 + 4 + 32 + 4 + 1 + 1 + 2 + 4 + 32, "ConnectionBody too short"); BodyTargetSupport.ensureMin(bb, 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(); 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); 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"); 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) { 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"); BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION");
this.lineCode = lineCode; this.prevLineNumber = prevLineNumber; this.prevConnectionBlockNumber = prevConnectionBlockNumber;
this.prevLineHash32 = prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32); this.prevConnectionBlockHash32 = prevConnectionBlockHash32 == null ? new byte[32] : Arrays.copyOf(prevConnectionBlockHash32, 32);
this.thisLineNumber = thisLineNumber; this.subType = subType; this.version = VER; this.connectionSequence = connectionSequence;
this.toLogin = toLogin; this.toForkNumber = toForkNumber; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); 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() { @Override public ConnectionBody check() {
if (lineCode < 0 || !isValidSubType(subType)) throw new IllegalArgumentException("Bad connection fields"); if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection fields");
if (prevLineNumber == -1) { if (prevConnectionBlockNumber < 0) throw new IllegalArgumentException("prevConnectionBlockNumber must be >=0");
if (!isAllZero32(prevLineHash32) || thisLineNumber != -1) throw new IllegalArgumentException("Bad empty connection line anchor"); if (prevConnectionBlockHash32 == null || prevConnectionBlockHash32.length != 32) throw new IllegalArgumentException("prevConnectionBlockHash32 invalid");
} else if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); if (connectionSequence <= 0) throw new IllegalArgumentException("connectionSequence must be >=1");
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION"); BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION");
return this; return this;
} }
@Override public byte[] toBytes() { @Override public byte[] toBytes() {
check(); check();
ByteBuffer bb = ByteBuffer.allocate(4 + 4 + 32 + 4 + BodyTargetSupport.externalSize(toLogin)).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.allocate(4 + 32 + 4 + BodyTargetSupport.externalSize(toLogin)).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode).putInt(prevLineNumber).put(prevLineHash32).putInt(thisLineNumber); bb.putInt(prevConnectionBlockNumber).put(prevConnectionBlockHash32).putInt(connectionSequence);
BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION"); BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION");
return bb.array(); return bb.array();
} }
@Override public int lineCode() { return lineCode; } @Override public int lineCode() { return 0; }
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } @Override public int prevLineBlockGlobalNumber() { return prevConnectionBlockNumber; }
@Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); } @Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevConnectionBlockHash32, 32); }
@Override public int lineSeq() { return thisLineNumber; } @Override public int lineSeq() { return connectionSequence; }
@Override public String toLogin() { return toLogin; } @Override public String toLogin() { return toLogin; }
@Override public Integer toForkNumber() { return toForkNumber; } @Override public Integer toForkNumber() { return toForkNumber; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @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_SHINE_SEEN & 0xFFFF) || v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF)
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF) || v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 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; }
} }
@@ -11,10 +11,15 @@ import java.util.Objects;
/** /**
* TECH_FORK body (type=0, subType=2, version=1). * TECH_FORK body (type=0, subType=2, version=1).
* *
* Записывается первым новым блоком после точной перепубликации выбранного * TECH_FORK является обычным шагом общей TECH-линии. Поэтому body начинается
* префикса старой цепочки новым blockchain key. * тем же line-prefix, что и TECH_CREATE_CHANNEL.
* *
* body bytes (BigEndian): * body bytes (BigEndian):
* [4] lineCode (=0 для TECH-линии)
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
*
* [32] parentBlockchainKey * [32] parentBlockchainKey
* [4] forkPointBlockNumber * [4] forkPointBlockNumber
* [32] forkPointBlockHash32 * [32] forkPointBlockHash32
@@ -27,7 +32,7 @@ import java.util.Objects;
* [2] commentUtf8Length * [2] commentUtf8Length
* [N] comment UTF-8 (0..1024 bytes) * [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 TYPE = 0;
public static final short VER = 1; 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 REASON_RECOVERY = 4;
public static final int MAX_COMMENT_UTF8_LEN = 1024; 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 subType;
public final short version; 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 byte[] parentBlockchainKey32;
public final int forkPointBlockNumber; public final int forkPointBlockNumber;
public final byte[] forkPointBlockHash32; public final byte[] forkPointBlockHash32;
@@ -71,6 +84,12 @@ public final class ForkBody implements BodyRecord {
} }
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); 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]; this.parentBlockchainKey32 = new byte[32];
bb.get(this.parentBlockchainKey32); bb.get(this.parentBlockchainKey32);
this.forkPointBlockNumber = bb.getInt(); this.forkPointBlockNumber = bb.getInt();
@@ -95,7 +114,11 @@ public final class ForkBody implements BodyRecord {
this.comment = new String(commentBytes, StandardCharsets.UTF_8); 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, int forkPointBlockNumber,
byte[] forkPointBlockHash32, byte[] forkPointBlockHash32,
long forkPointTimestampMs, long forkPointTimestampMs,
@@ -107,6 +130,10 @@ public final class ForkBody implements BodyRecord {
String comment) { String comment) {
this.subType = SUBTYPE; this.subType = SUBTYPE;
this.version = VER; this.version = VER;
this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = copy32(prevLineHash32, "prevLineHash32");
this.thisLineNumber = thisLineNumber;
this.parentBlockchainKey32 = copy32(parentBlockchainKey32, "parentBlockchainKey32"); this.parentBlockchainKey32 = copy32(parentBlockchainKey32, "parentBlockchainKey32");
this.forkPointBlockNumber = forkPointBlockNumber; this.forkPointBlockNumber = forkPointBlockNumber;
this.forkPointBlockHash32 = copy32(forkPointBlockHash32, "forkPointBlockHash32"); this.forkPointBlockHash32 = copy32(forkPointBlockHash32, "forkPointBlockHash32");
@@ -127,6 +154,11 @@ public final class ForkBody implements BodyRecord {
if ((version & 0xFFFF) != (VER & 0xFFFF)) { if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody version must be 1"); 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(parentBlockchainKey32, "parentBlockchainKey32");
require32(forkPointBlockHash32, "forkPointBlockHash32"); require32(forkPointBlockHash32, "forkPointBlockHash32");
require32(parentTipBlockHash32, "parentTipBlockHash32"); require32(parentTipBlockHash32, "parentTipBlockHash32");
@@ -155,6 +187,10 @@ public final class ForkBody implements BodyRecord {
check(); check();
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8); byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
ByteBuffer bb = ByteBuffer.allocate(FIXED_LEN + commentUtf8.length).order(ByteOrder.BIG_ENDIAN); 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.put(parentBlockchainKey32);
bb.putInt(forkPointBlockNumber); bb.putInt(forkPointBlockNumber);
bb.put(forkPointBlockHash32); bb.put(forkPointBlockHash32);
@@ -169,6 +205,11 @@ public final class ForkBody implements BodyRecord {
return bb.array(); 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) { public static boolean isReasonSupported(int code) {
return code == REASON_ROUTINE_ROTATION return code == REASON_ROUTINE_ROTATION
|| code == REASON_POSSIBLE_COMPROMISE || code == REASON_POSSIBLE_COMPROMISE
@@ -11,16 +11,16 @@ import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
/** /**
* UserParamBody — type=4, ver=1 (в заголовке блока). * UserParamBody — type=4, ver=1 (в документации: USER_PROFILE).
* *
* subType (в заголовке блока): * subType (в заголовке блока):
* 1 = TEXT_TEXT * 1 = TEXT_TEXT
* *
* bodyBytes (BigEndian), новый формат: * USER_PROFILE имеет единственную фиксированную линию, поэтому lineCode в body не хранится.
* [4] lineCode * bodyBytes (BigEndian):
* [4] prevLineNumber * [4] prevProfileBlockNumber — GLOBAL blockNumber предыдущего USER_PROFILE-блока
* [32] prevLineHash32 * [32] prevProfileBlockHash32 — hash32 предыдущего USER_PROFILE-блока
* [4] thisLineNumber * [4] profileSequence — порядковый номер изменения профиля (1..N)
* *
* [2] keyLenBytes (uint16) * [2] keyLenBytes (uint16)
* [N] keyUtf8 * [N] keyUtf8
@@ -35,14 +35,12 @@ public final class UserParamBody implements BodyRecord, BodyHasLine {
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header public final short subType;
public final short version; // из header public final short version;
// line public final int prevProfileBlockNumber;
public final int lineCode; public final byte[] prevProfileBlockHash32;
public final int prevLineNumber; public final int profileSequence;
public final byte[] prevLineHash32;
public final int thisLineNumber;
public final String paramKey; public final String paramKey;
public final String paramValue; public final String paramValue;
@@ -60,21 +58,17 @@ public final class UserParamBody implements BodyRecord, BodyHasLine {
throw new IllegalArgumentException("Bad UserParam subType: " + (this.subType & 0xFFFF)); throw new IllegalArgumentException("Bad UserParam subType: " + (this.subType & 0xFFFF));
} }
// минимум: lineCode(4)+line(4+32+4) + keyLen(2)+key(1) + valLen(2)+val(1) // prevGlobal(4)+prevHash(32)+seq(4) + keyLen(2)+key(1) + valLen(2)+val(1)
if (bodyBytes.length < 4 + (4 + 32 + 4) + 2 + 1 + 2 + 1) { if (bodyBytes.length < 4 + 32 + 4 + 2 + 1 + 2 + 1) {
throw new IllegalArgumentException("UserParamBody too short"); throw new IllegalArgumentException("UserParamBody too short");
} }
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.lineCode = bb.getInt(); this.prevProfileBlockNumber = bb.getInt();
this.prevProfileBlockHash32 = new byte[32];
this.prevLineNumber = bb.getInt(); bb.get(this.prevProfileBlockHash32);
this.profileSequence = bb.getInt();
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
int keyLen = Short.toUnsignedInt(bb.getShort()); int keyLen = Short.toUnsignedInt(bb.getShort());
if (keyLen <= 0) throw new IllegalArgumentException("paramKeyLen is 0"); 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"); if (this.paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank");
} }
public UserParamBody(int lineCode, public UserParamBody(int prevProfileBlockNumber,
int prevLineNumber, byte[] prevProfileBlockHash32,
byte[] prevLineHash32, int profileSequence,
int thisLineNumber,
String paramKey, String paramKey,
String paramValue) { String paramValue) {
Objects.requireNonNull(paramKey, "paramKey == null"); Objects.requireNonNull(paramKey, "paramKey == null");
Objects.requireNonNull(paramValue, "paramValue == null"); Objects.requireNonNull(paramValue, "paramValue == null");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
this.subType = MsgSubType.USER_PARAM_TEXT_TEXT; this.subType = MsgSubType.USER_PARAM_TEXT_TEXT;
this.version = VER; this.version = VER;
this.lineCode = lineCode; this.prevProfileBlockNumber = prevProfileBlockNumber;
this.prevLineNumber = prevLineNumber; this.prevProfileBlockHash32 = (prevProfileBlockHash32 == null ? new byte[32] : Arrays.copyOf(prevProfileBlockHash32, 32));
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); this.profileSequence = profileSequence;
this.thisLineNumber = thisLineNumber;
if (paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); if (paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank");
if (paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); if (paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank");
@@ -128,17 +118,12 @@ public final class UserParamBody implements BodyRecord, BodyHasLine {
@Override @Override
public UserParamBody check() { 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)); 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 (paramKey == null || paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank");
if (paramValue == null || paramValue.isBlank()) throw new IllegalArgumentException("paramValue 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 @Override
public byte[] toBytes() { public byte[] toBytes() {
check();
byte[] keyUtf8 = paramKey.getBytes(StandardCharsets.UTF_8); byte[] keyUtf8 = paramKey.getBytes(StandardCharsets.UTF_8);
byte[] valUtf8 = paramValue.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 (keyUtf8.length == 0 || keyUtf8.length > 65535) throw new IllegalArgumentException("paramKey utf8 len must be 1..65535");
if (valUtf8.length == 0 || valUtf8.length > 65535) throw new IllegalArgumentException("paramValue utf8 len must be 1..65535"); if (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 + keyUtf8.length
+ 2 + valUtf8.length; + 2 + valUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(prevProfileBlockNumber);
bb.putInt(lineCode); bb.put(prevProfileBlockHash32);
bb.putInt(profileSequence);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.putShort((short) keyUtf8.length); bb.putShort((short) keyUtf8.length);
bb.put(keyUtf8); bb.put(keyUtf8);
bb.putShort((short) valUtf8.length); bb.putShort((short) valUtf8.length);
bb.put(valUtf8); bb.put(valUtf8);
return bb.array(); return bb.array();
} }
@@ -187,15 +167,9 @@ public final class UserParamBody implements BodyRecord, BodyHasLine {
} }
} }
private static boolean isAllZero32(byte[] b) { /* USER_PROFILE имеет единственную линию, логический lineCode всегда 0. */
if (b == null || b.length != 32) return true; @Override public int lineCode() { return 0; }
for (int i = 0; i < 32; i++) if (b[i] != 0) return false; @Override public int prevLineBlockGlobalNumber() { return prevProfileBlockNumber; }
return true; @Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevProfileBlockHash32, 32); }
} @Override public int lineSeq() { return profileSequence; }
/* ====================== 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; }
} }
@@ -16,6 +16,10 @@ final class ForkBodyTest {
byte[] tipHash = filled(32, 0x33); byte[] tipHash = filled(32, 0x33);
ForkBody source = new ForkBody( ForkBody source = new ForkBody(
0,
77,
filled(32, 0x44),
5,
parentKey, parentKey,
120, 120,
forkHash, forkHash,
@@ -33,6 +37,9 @@ final class ForkBodyTest {
BodyRecordParser.parse(ForkBody.TYPE, ForkBody.SUBTYPE, ForkBody.VER, source.toBytes()) 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); assertArrayEquals(parentKey, parsed.parentBlockchainKey32);
assertEquals(120, parsed.forkPointBlockNumber); assertEquals(120, parsed.forkPointBlockNumber);
assertArrayEquals(forkHash, parsed.forkPointBlockHash32); assertArrayEquals(forkHash, parsed.forkPointBlockHash32);
@@ -46,6 +53,10 @@ final class ForkBodyTest {
@Test @Test
void routineRotationMayDiscardNothing() { void routineRotationMayDiscardNothing() {
ForkBody body = new ForkBody( ForkBody body = new ForkBody(
0,
0,
filled(32, 9),
1,
filled(32, 1), filled(32, 1),
10, 10,
filled(32, 2), filled(32, 2),
@@ -63,6 +74,10 @@ final class ForkBodyTest {
@Test @Test
void rejectedWhenDiscardCountDoesNotMatchForkPointAndTip() { void rejectedWhenDiscardCountDoesNotMatchForkPointAndTip() {
ForkBody body = new ForkBody( ForkBody body = new ForkBody(
0,
0,
filled(32, 9),
1,
filled(32, 1), filled(32, 1),
10, 10,
filled(32, 2), filled(32, 2),
@@ -59,7 +59,7 @@ final class TargetLoginFormatTest {
assertEquals(5, parsedEditPost.toBlockGlobalNumber()); assertEquals(5, parsedEditPost.toBlockGlobalNumber());
ConnectionBody connection = new ConnectionBody( ConnectionBody connection = new ConnectionBody(
0, -1, new byte[32], -1, 0, HASH, 1,
MsgSubType.CONNECTION_FOLLOW, MsgSubType.CONNECTION_FOLLOW,
"alice", 3, 0, HASH "alice", 3, 0, HASH
); );
@@ -84,16 +84,10 @@ final class TargetLoginFormatTest {
} }
@Test @Test
void forkZeroIsRejectedForNewProtocol() { void forkZeroIsReservedButStillDecodableAsHistoricalUnknown() {
assertThrows(IllegalArgumentException.class, ReactionBody source = new ReactionBody(MsgSubType.REACTION_LIKE, "alice", 0, 42, HASH);
() -> 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());
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));
} }
@Test @Test
@@ -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);} } 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<BlockEntry> listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} } public List<BlockEntry> listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} }
public List<BlockEntry> listRangeByNumber(Connection c,String bchName,int from,int to)throws SQLException{ public List<BlockEntry> 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"; String sql=baseSelect()+" WHERE login=? AND fork_number=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC";
@@ -523,7 +523,7 @@ CREATE TABLE IF NOT EXISTS blocks (
block_bytes BYTEA NOT NULL, block_bytes BYTEA NOT NULL,
to_login TEXT, to_login TEXT,
to_bch_name 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_number INTEGER CHECK (to_block_number IS NULL OR to_block_number >= 0),
to_block_hash BYTEA, to_block_hash BYTEA,
block_hash BYTEA NOT NULL, block_hash BYTEA NOT NULL,
@@ -1110,6 +1110,7 @@ LANGUAGE plpgsql
AS $$ AS $$
DECLARE DECLARE
prev_block RECORD; prev_block RECORD;
tail_block_number INTEGER;
BEGIN BEGIN
IF NEW.line_code IS NULL IF NEW.line_code IS NULL
AND NEW.prev_line_number IS NULL AND NEW.prev_line_number IS NULL
@@ -1149,27 +1150,36 @@ BEGIN
RAISE EXCEPTION 'LINE_ERR_LINE_CODE_MISMATCH'; RAISE EXCEPTION 'LINE_ERR_LINE_CODE_MISMATCH';
END IF; 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 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'; RAISE EXCEPTION 'LINE_ERR_FIRST_STEP_BAD_THIS';
END IF; END IF;
RETURN NEW; RETURN NEW;
END IF; 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 IF prev_block.this_line_number IS NULL THEN
RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP';
END IF; 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 IF NEW.this_line_number <> prev_block.this_line_number + 1 THEN
RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP';
END IF; END IF;
END IF;
RETURN NEW; RETURN NEW;
END; END;
@@ -2436,7 +2446,7 @@ $$;
-- Physical chain integrity uses numeric fork identity. -- Physical chain integrity uses numeric fork identity.
CREATE OR REPLACE FUNCTION shine_blocks_line_integrity_bi() RETURNS TRIGGER LANGUAGE plpgsql AS $$ 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 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.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; 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 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 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 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 SELECT block_number INTO tail_block_number FROM blocks_store
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; 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; 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 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;
END IF;
RETURN NEW; RETURN NEW;
END $$; END $$;
@@ -8,6 +8,7 @@ import blockchain.body.BodyHasTarget;
import blockchain.body.ConnectionBody; import blockchain.body.ConnectionBody;
import blockchain.body.CreateChannelBody; import blockchain.body.CreateChannelBody;
import blockchain.body.HeaderBody; import blockchain.body.HeaderBody;
import blockchain.body.ForkBody;
import blockchain.body.StatusActionBody; import blockchain.body.StatusActionBody;
import blockchain.body.TextReplyBody; import blockchain.body.TextReplyBody;
import blockchain.body.TextLineBody; import blockchain.body.TextLineBody;
@@ -195,6 +196,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "limit_check_failed" -> "Ошибка проверки лимита размера"; case "limit_check_failed" -> "Ошибка проверки лимита размера";
case "bad_block_format" -> "Некорректный формат блока"; case "bad_block_format" -> "Некорректный формат блока";
case "bad_app_tag" -> "Некорректный тестовый тег ANS-104"; case "bad_app_tag" -> "Некорректный тестовый тег ANS-104";
case "bad_type_tag" -> "Некорректный индексный t-тег ANS-104";
case "bad_channel_tag" -> "Некорректный тег канала ANS-104"; case "bad_channel_tag" -> "Некорректный тег канала ANS-104";
case "bad_block_body" -> "Некорректное тело блока"; case "bad_block_body" -> "Некорректное тело блока";
case "bad_channel_name" -> "Некорректное название канала"; case "bad_channel_name" -> "Некорректное название канала";
@@ -204,9 +206,15 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "bad_blockchain_key_len" -> "Некорректный ключ блокчейна в состоянии (ожидалось 32 байта)"; case "bad_blockchain_key_len" -> "Некорректный ключ блокчейна в состоянии (ожидалось 32 байта)";
case "signature_verify_failed" -> "Ошибка проверки подписи блока"; case "signature_verify_failed" -> "Ошибка проверки подписи блока";
case "bad_signature" -> "Некорректная подпись блока"; 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 "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 "channel_name_already_exists" -> "Такое название канала уже занято";
case "repost_disabled" -> "Репосты временно отключены до будущей реализации"; case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
case "entrypoint_edit_forbidden" -> "TEXT_ENTRYPOINT нельзя редактировать через TEXT_EDIT_POST"; 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); 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() // body.check()
try { try {
block.body.check(); 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); 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 // 6) ANS-104 Ed25519 подпись и owner
boolean sigOk; boolean sigOk;
try { try {
@@ -556,7 +580,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex); 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 lineCode = null;
Integer prevLineNumber = null; Integer prevLineNumber = null;
byte[] prevLineHash32 = null; byte[] prevLineHash32 = null;
@@ -568,33 +592,17 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
prevLineHash32 = bl.prevLineBlockHash32(); prevLineHash32 = bl.prevLineBlockHash32();
thisLineNumber = bl.lineSeq(); 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 { try {
byte[] dbPrevHash = blocksDAO.getHashByNumber(blockchainName, prevLineNumber); String lineError = validateLineContinuity(blockchainName, block, bl);
if (dbPrevHash == null) { if (lineError != null) {
log.warn("AddBlock: prev_line_block_not_found (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})", log.warn("AddBlock: {} (login={}, blockchainName={}, blockNumber={}, lineCode={}, prevLineNumber={}, lineSeq={})",
login, blockchainName, block.blockNumber, prevLineNumber); lineError, login, blockchainName, block.blockNumber, lineCode, prevLineNumber, thisLineNumber);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "prev_line_block_not_found", serverLastNum, serverLastHashHex); return new AddBlockResult(WireCodes.Status.BAD_REQUEST, lineError, 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) { } catch (Exception e) {
log.error("AddBlock: db_error_prev_line_check (login={}, blockchainName={}, blockNumber={})", log.error("AddBlock: db_error_line_check (login={}, blockchainName={}, blockNumber={})",
login, blockchainName, block.blockNumber, e); login, blockchainName, block.blockNumber, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error_prev_line_check", serverLastNum, serverLastHashHex); 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 ====================================== */ /* ====================== 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 { private String expectedChannelSlug(String blockchainName, BchBlockEntry block, ChannelNameStateEntry justCreated) throws Exception {
if (block.body instanceof CreateChannelBody) { if (block.body instanceof CreateChannelBody) {
return justCreated == null ? null : justCreated.getSlug(); return justCreated == null ? null : justCreated.getSlug();
@@ -101,6 +101,11 @@ public final class Net_KeyRotationAddBlock_Handler implements JsonMessageHandler
if (!block.getDataItem().hasTag("App", "test5590")) { if (!block.getDataItem().hasTag("App", "test5590")) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_APP_TAG", "Отсутствует 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)) { if (!requestPrevHashMatches(req.getPrevBlockHash(), block.prevHash32)) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_PREV_HASH_MISMATCH", "prevBlockHash запроса не совпадает с блоком"); 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 ""; if (value == null) return "";
return value.trim().replace("\r\n", "\n").replace('\r', '\n'); 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;
};
}
} }
@@ -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.getHash32(), stored.getBlockHash())) return "KEY_ROTATION_STORED_HASH_MISMATCH";
if (!Arrays.equals(candidate.getDataItemId32(), stored.getDataItemId())) return "KEY_ROTATION_STORED_DATAITEM_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"; 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 (!BchCryptoVerifier.verifyBlock(candidate, newBlockchainKey)) return "KEY_ROTATION_BAD_SIGNATURE";
if (i == 0) { if (i == 0) {
@@ -215,4 +218,14 @@ public final class Net_KeyRotationFinishChain_Handler implements JsonMessageHand
if (value == null) return ""; if (value == null) return "";
return value.trim().replace("\r\n", "\n").replace('\r', '\n'); 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;
};
}
} }
@@ -81,6 +81,8 @@ public final class AddBlockSender {
List<Ans104DataItem.Tag> tags = new ArrayList<>(); List<Ans104DataItem.Tag> tags = new ArrayList<>();
tags.add(new Ans104DataItem.Tag("App", "test5590")); 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); String channelSlug = channelSlugFor(body);
if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c_test5590", channelSlug)); 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) { private static short typeOf(BodyRecord body) {
if (body instanceof HeaderBody) return HeaderBody.TYPE; if (body instanceof HeaderBody) return HeaderBody.TYPE;
if (body instanceof CreateChannelBody) return CreateChannelBody.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 TextLineBody || body instanceof TextReplyBody || body instanceof TextRatingBody) return TextLineBody.TYPE;
if (body instanceof StatusActionBody) return StatusActionBody.TYPE; if (body instanceof StatusActionBody) return StatusActionBody.TYPE;
if (body instanceof ReactionBody) return ReactionBody.TYPE; if (body instanceof ReactionBody) return ReactionBody.TYPE;
@@ -178,6 +181,7 @@ public final class AddBlockSender {
private static short subTypeOf(BodyRecord body) { private static short subTypeOf(BodyRecord body) {
if (body instanceof HeaderBody hb) return hb.subType; if (body instanceof HeaderBody hb) return hb.subType;
if (body instanceof CreateChannelBody cb) return cb.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 TextLineBody tb) return tb.subType;
if (body instanceof TextReplyBody tb) return tb.subType; if (body instanceof TextReplyBody tb) return tb.subType;
if (body instanceof TextRatingBody 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) { private static short versionOf(BodyRecord body) {
if (body instanceof HeaderBody hb) return hb.version; if (body instanceof HeaderBody hb) return hb.version;
if (body instanceof CreateChannelBody cb) return cb.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 TextLineBody tb) return tb.version;
if (body instanceof TextReplyBody tb) return tb.version; if (body instanceof TextReplyBody tb) return tb.version;
if (body instanceof TextRatingBody 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 ---------- // ---------- 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) { private String channelSlugFor(BodyRecord body) {
if (body instanceof CreateChannelBody ccb) { if (body instanceof CreateChannelBody ccb) {
return shine.db.channels.ChannelNameRules.toCanonicalSlug(ccb.channelName); return shine.db.channels.ChannelNameRules.toCanonicalSlug(ccb.channelName);
@@ -3,6 +3,7 @@ package test.it.blockchain;
import blockchain.body.BodyRecord; import blockchain.body.BodyRecord;
import blockchain.body.BodyHasLine; import blockchain.body.BodyHasLine;
import blockchain.body.CreateChannelBody; import blockchain.body.CreateChannelBody;
import blockchain.body.ForkBody;
import blockchain.body.TextLineBody; import blockchain.body.TextLineBody;
import java.util.HashMap; import java.util.HashMap;
@@ -16,10 +17,10 @@ import java.util.Map;
* - map blockNumber -> hash32 * - 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") * - TEXT (type=1): линии каналов, root = HEADER (канал "0") или CREATE_CHANNEL (канал "X")
* - CONNECTION (type=3): одна линия * - CONNECTION (type=3): одна линия
* - USER_PARAM (type=4): одна линия * - USER_PROFILE (type=4): одна линия
* *
* ВАЖНО: * ВАЖНО:
* - prevLineNumber — это GLOBAL blockNumber предыдущего блока линии. * - prevLineNumber — это GLOBAL blockNumber предыдущего блока линии.
@@ -277,16 +278,18 @@ public final class ChainState {
int t = type & 0xFFFF; int t = type & 0xFFFF;
// ---- TECH (CREATE_CHANNEL) ---- // ---- TECH (CREATE_CHANNEL / TECH_FORK) ----
if (t == TYPE_TECH && body instanceof CreateChannelBody ccb) { if (t == TYPE_TECH && body instanceof BodyHasLine tech) {
techLine.lastGlobalNumber = blockNumber; techLine.lastGlobalNumber = blockNumber;
techLine.lastHashHex = hex64; techLine.lastHashHex = hex64;
techLine.lastThisLineNumber = ccb.thisLineNumber; techLine.lastThisLineNumber = tech.lineSeq();
// ВАЖНО: CREATE_CHANNEL — это root нового текстового канала: if (body instanceof CreateChannelBody) {
// lineCode для этого канала = blockNumber CREATE_CHANNEL // CREATE_CHANNEL одновременно является root нового текстового канала.
registerTextChannelRoot(blockNumber, hash32); registerTextChannelRoot(blockNumber, hash32);
} else if (!(body instanceof ForkBody)) {
throw new IllegalStateException("Unknown TECH line body: " + body.getClass());
}
return; return;
} }
@@ -331,11 +331,7 @@ public class IT_03_AddBlock_NoAuth {
var ln = st.nextLineByType(ChainState.TYPE_CONNECTION); 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( sender.send(new ConnectionBody(
0, // lineCode для connection линии
ln.prevLineNumber, ln.prevLineNumber,
ln.prevLineHash32, ln.prevLineHash32,
ln.thisLineNumber, ln.thisLineNumber,
@@ -132,7 +132,6 @@ public final class SeedDataPopulationHelper {
ChainState state = stateFor(login); ChainState state = stateFor(login);
ChainState.NextLine line = state.nextLineByType(ChainState.TYPE_USER_PARAM); ChainState.NextLine line = state.nextLineByType(ChainState.TYPE_USER_PARAM);
sender.send(new UserParamBody( sender.send(new UserParamBody(
0,
line.prevLineNumber, line.prevLineNumber,
line.prevLineHash32, line.prevLineHash32,
line.thisLineNumber, line.thisLineNumber,
@@ -148,7 +147,6 @@ public final class SeedDataPopulationHelper {
ChainState.NextLine line = state.nextLineByType(ChainState.TYPE_CONNECTION); ChainState.NextLine line = state.nextLineByType(ChainState.TYPE_CONNECTION);
sender.send(new ConnectionBody( sender.send(new ConnectionBody(
0,
line.prevLineNumber, line.prevLineNumber,
line.prevLineHash32, line.prevLineHash32,
line.thisLineNumber, line.thisLineNumber,
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.14.4 client.version=1.14.5
server.version=1.12.2 server.version=1.12.3
@@ -10,7 +10,7 @@
- `11_TEXT_Blocks.md` — текстовые блоки (`type=1`). - `11_TEXT_Blocks.md` — текстовые блоки (`type=1`).
- `12_REACTION_Blocks.md` — реакции (`type=2`). - `12_REACTION_Blocks.md` — реакции (`type=2`).
- `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`). - `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`). - `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=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE.
- `type=2` — REACTION: LIKE/UNLIKE. - `type=2` — REACTION: LIKE/UNLIKE.
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции. - `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. - `type=5` — STATUS_ACTION: DONE_ONCE/LEARNED/SERVICE_PASSED/CONFIRMED/INTERESTED/STARTED/IN_STUDY/ABANDONED/COMPLETED.
## Примечание ## Примечание
+21 -7
View File
@@ -77,6 +77,18 @@ App = test5590
Это временное namespace-значение для разработки. Перед реальным запуском оно будет заменено отдельным изменением протокола/кода. Это временное 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; 1. распарсить полный ANS-104 DataItem;
2. проверить `App=test5590`; 2. проверить `App=test5590`;
3. проверить `c_test5590`, если тип блока требует канал; 3. проверить `t`, если тип блока требует фиксированный индексный поток;
4. проверить ANS-104 Ed25519 подпись; 4. проверить `c_test5590`, если тип блока требует канал;
5. проверить, что `owner` равен текущему blockchain public key пользователя; 5. проверить ANS-104 Ed25519 подпись;
6. распарсить Frame v1 и body; 6. проверить, что `owner` равен текущему blockchain public key пользователя;
7. проверить `blockNumber == last + 1`; 7. распарсить Frame v1 и body;
8. проверить `prevHash32 == lastBlockHash`; 8. проверить `blockNumber == last + 1`;
9. записать DataItem и новое состояние атомарно в PostgreSQL. 9. проверить `prevHash32 == lastBlockHash`;
10. проверить строгую непрерывность логической линии, если body line-based;
11. записать DataItem и новое состояние атомарно в PostgreSQL.
## Нормативное правило версий body ## Нормативное правило версий body
@@ -2,47 +2,87 @@
## 1. Именованный блокчейн ## 1. Именованный блокчейн
Базовый идентификатор цепочки пользователя: Базовый идентификатор физической цепочки пользователя:
- `blockchainName = <login>-<NNN>` - `blockchainName = <login>-<NNN>`;
- `NNN` — реальный номер fork строго `001..999`; `000` и `1000+` недопустимы; - `NNN` — номер fork `001..999`;
- пример: `alice-001` - пример: `alice-001`.
Обычно это одна основная цепочка пользователя. ## 2. Логические линии внутри пользовательской цепочки
## 2. Логические линии внутри одной цепочки Глобально все блоки пользователя идут одной последовательностью `blockNumber` и `prevHash32`. Дополнительно некоторые типы образуют собственные проверяемые линии.
Физически цепочка одна, но внутри есть независимые логические последовательности (линии), которые ведутся через поля: Актуальные линии:
- `lineCode` - `TECH` — системная история: `TECH_CREATE_CHANNEL`, `TECH_FORK`; root — `HEADER`;
- `prevLineNumber` - `TEXT` — отдельная линия каждого канала; root — `HEADER` для канала `0` или `TECH_CREATE_CHANNEL` для пользовательского канала;
- `prevLineHash32` - `CONNECTION` — единая история изменений связей пользователя; root — `HEADER`;
- `thisLineNumber` - `USER_PROFILE` — единая история изменений профиля пользователя; root — `HEADER`.
Линии используются для: `STATUS_ACTION` в line-модель сейчас не входит.
- TECH-событий;
- каналов с текстовыми постами;
- связей и подписок;
- пользовательских параметров.
## 3. Правила line-полей (фактическая серверная валидация) ## 3. Два формата line-prefix
Line-поля: `lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`. ### 3.1. Линии с явным `lineCode`
- Line-поля разрешены только для `msg_type`: `0`, `1`, `3`, `4`. `TECH` и `TEXT` хранят:
- Если передано хотя бы одно 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`.
## 4. Root-идея для каналов и подписок ```text
[4] lineCode
[4] prevLineNumber
[32] prevLineHash32
[4] thisLineNumber
```
Для ссылок вида follow/friend/contact принято ссылаться на корневые блоки: - для TECH `lineCode=0`;
- `HEADER` для базовой сущности пользователя/канала `0`; - для канала `lineCode = global blockNumber` его root-блока (`HEADER #0` либо `TECH_CREATE_CHANNEL`).
- `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.
+77 -28
View File
@@ -1,26 +1,76 @@
# TECH блоки (`type=0`, `version=1`) # 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` ### `subType=0` — `HEADER_COMPAT`
- стартовый блок цепочки;
- payload: tag `SHiNE` + login владельца + `initialBlockchainKey32`;
- `initialBlockchainKey32` — public blockchain-signing key, которым был создан fork №1; это историческая точка происхождения и при последующих fork сохраняется в скопированном HEADER байт-в-байт.
2. `subType=1` — `TECH_CREATE_CHANNEL` Стартовый блок цепочки (`block #0`). Payload содержит tag `SHiNE`, login владельца и `initialBlockchainKey32`.
- создание нового канала;
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`.
3. `subType=2` — `TECH_FORK` `initialBlockchainKey32` — public blockchain-signing key, которым был создан fork №1; это историческая точка происхождения. При последующих fork значение не меняется.
- первый новый блок после точной перепубликации выбранного префикса предыдущего fork новым blockchain key;
- связывает новую активную цепочку с предыдущей и фиксирует точку rollback/продолжения.
### `TECH_FORK` body (`version=1`) HEADER сам не хранит line-prefix, но является root TECH-линии.
### `subType=1` — `TECH_CREATE_CHANNEL`
Создание нового канала. Блок одновременно:
- является очередным шагом TECH-линии;
- является root нового канала;
- имеет `t=tech`;
- имеет `c_test5590=<canonical_channel_slug>`.
Начало 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: 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; - `parentBlockchainKey[32]` — public key предыдущего fork;
- `forkPointBlockNumber[4]` — последний блок старой цепочки, сохранённый в новом fork; - `forkPointBlockNumber[4]` — последний блок старой цепочки, сохранённый в новом fork;
- `forkPointBlockHash32[32]`; - `forkPointBlockHash32[32]`;
@@ -28,28 +78,27 @@ Big-endian:
- `parentTipBlockNumber[4]` — tip старой цепочки на момент начала ротации; - `parentTipBlockNumber[4]` — tip старой цепочки на момент начала ротации;
- `parentTipBlockHash32[32]`; - `parentTipBlockHash32[32]`;
- `parentTipTimestampMs[8]`; - `parentTipTimestampMs[8]`;
- `discardedBlocksCount[4]` — `parentTipBlockNumber - forkPointBlockNumber`; - `discardedBlocksCount[4] = parentTipBlockNumber - forkPointBlockNumber`;
- `reasonCode[1]`; - `reasonCode[1]`;
- `commentUtf8Length[2]`; - `commentUtf8Length[2]`;
- `comment[N]` — произвольный комментарий пользователя, максимум 1024 UTF-8 байт. - `comment[N]` — максимум 1024 UTF-8 байт.
`reasonCode`: `reasonCode`:
- `1` — `ROUTINE_ROTATION`: обычная смена пароля/ключей, компрометация не предполагается; - `1` — `ROUTINE_ROTATION`;
- `2` — `POSSIBLE_COMPROMISE`: возможная компрометация, неизвестные записи не подтверждены; - `2` — `POSSIBLE_COMPROMISE`;
- `3` — `CONFIRMED_COMPROMISE_ROLLBACK`: обнаружены нежелательные/чужие записи и выполнен rollback; - `3` — `CONFIRMED_COMPROMISE_ROLLBACK`;
- `4` — `RECOVERY`: восстановление доступа recovery-механизмом. - `4` — `RECOVERY`.
Правила: Текущая схема ротации перепубликует выбранный префикс `0..forkPointBlockNumber` новым blockchain key и затем добавляет `TECH_FORK`. Перепубликованные Frame сохраняются байт-в-байт; внешняя ANS-104 owner/signature меняется. Новый blockchain key в body не дублируется: он определяется owner/signature нового DataItem.
- блоки `0..forkPointBlockNumber` в новом fork должны быть точными Frame-копиями выбранного префикса предыдущей цепочки; ## Серверная проверка TECH-линии
- `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_CREATE_CHANNEL` и `TECH_FORK` сервер требует:
- инициализация блокчейна; 1. `lineCode=0`;
- управление набором каналов пользователя; 2. первый TECH-событийный блок ссылается на HEADER и имеет sequence `1`;
- фиксация происхождения нового fork и причины ротации/rollback. 3. каждый следующий ссылается на фактический текущий хвост TECH-линии;
4. номер предыдущего блока, его hash и sequence должны совпадать точно.
Таким образом CREATE_CHANNEL и FORK образуют единый проверяемый технический позвоночник пользователя.
+49 -22
View File
@@ -1,41 +1,68 @@
# CONNECTION блоки (`type=3`, `version=1`) # CONNECTION блоки (`type=3`, `version=1`)
CONNECTION-тип описывает социальные связи и подписки. CONNECTION описывает историю социальных связей и подписок пользователя.
## Подтипы ## Подтипы
• `10/11` — `close_friend / unclose_friend` (близкий друг) • `10/11` — `close_friend / unclose_friend` (близкий друг)
• `14/15` — `friend / unfriend` (друг)
• `20/21` — `contact / uncontact` (контакт) • `20/21` — `contact / uncontact` (контакт)
• `30/31` — `follow / unfollow` (подписан) • `30/31` — `follow / unfollow` (подписка)
• `40/41` — `spouse / unspouse` (супруг/супруга) • `40/41` — `spouse / unspouse`
• `50/51` — `parent / unparent` (родитель) • `50/51` — `parent / unparent`
• `52/53` — `child / unchild` (ребёнок) • `52/53` — `child / unchild`
• `54/55` — `sibling / unsibling` (брат/сестра) • `54/55` — `sibling / unsibling`
• `60/61` — `known_person / unknown_person` (знаю этого человека) • `70/71` — `shine_confirmed / shine_unconfirmed`
• `70/71` — `shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий) • `74/75` — `shine_seen / shine_unseen`
• `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 ```text
[1] toLoginLen (uint8) [4] prevConnectionBlockNumber
[32] prevConnectionBlockHash32
[4] connectionSequence
[1] toLoginLen
[N] toLogin UTF-8 [N] toLogin UTF-8
[2] toForkNumber (uint16, BigEndian) [2] toForkNumber
[4] toBlockGlobalNumber [4] toBlockGlobalNumber
[32] toBlockHash32 [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`). ## Target
- FOLLOW указывает на root канала:
- `HEADER` для канала `0`; `toForkNumber` хранит fork, который видел автор связи (`1..999`), но не входит в логическую identity цели. `0` зарезервирован как unknown/legacy; `1000+` недопустим.
- `CREATE_CHANNEL` для пользовательского канала.
- Для остальных типов связи (`SPOUSE/PARENT/CHILD/SIBLING`) используется тот же target-формат. Для связи на пользователя обычно используется его HEADER:
```text
toBlockGlobalNumber=0
toBlockHash32=SHA-256(Frame HEADER)
```
Для FOLLOW на канал target указывает на root канала: HEADER для канала `0` либо `TECH_CREATE_CHANNEL` для пользовательского канала.
## ANS-104 тег
Каждый CONNECTION обязан иметь подписанный тег:
```text
t=conn
```
-14
View File
@@ -1,14 +0,0 @@
# USER_PARAM блоки (`type=4`, `version=1`)
## Подтипы
1. `subType=1` — `USER_PARAM_TEXT_TEXT`
- хранит line-поля + `paramKey` + `paramValue`.
## Назначение
- сохранение пользовательского состояния (настройки клиента, синк-метки, курсоры чтения и т.д.).
## Практика
Для сложных структур удобно хранить JSON-строку в `paramValue` с версией схемы.
+45
View File
@@ -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
```
+11 -1
View File
@@ -14,13 +14,23 @@
App=test5590 App=test5590
``` ```
Для фиксированных потоков:
```text
t=tech # TECH: HEADER / CREATE_CHANNEL / FORK
t=conn # CONNECTION
t=prof # USER_PROFILE
```
Для блоков конкретного канала дополнительно: Для блоков конкретного канала дополнительно:
```text ```text
c_test5590=<canonical_channel_slug> c_test5590=<canonical_channel_slug>
``` ```
Теги входят в ANS-104 подпись пользователя. Старый тестовый тег `c` новым кодом не создаётся и не принимается как channel tag. `TECH_CREATE_CHANNEL` несёт оба индекса: `t=tech` и `c_test5590=<slug>`. `STATUS_ACTION` не получает `t`-тег.
Теги входят в ANS-104 подпись пользователя. Сервер проверяет соответствие `t` фактическому `msg_type` и отклоняет лишний/неверный `t`. Старый тестовый тег `c` новым кодом не создаётся и не принимается как channel tag.
## Publisher modes ## Publisher modes
+11
View File
@@ -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 ## 2026-09-27 — TECH_FORK v1
- Добавлен `type=0 / subType=2 / version=1` (`TECH_FORK`). - Добавлен `type=0 / subType=2 / version=1` (`TECH_FORK`).
@@ -9,6 +9,7 @@
- Importer всегда ищет только individual `App=test5590` DataItems. - Importer всегда ищет только individual `App=test5590` DataItems.
- `App=test5590-batch` больше не используется. - `App=test5590-batch` больше не используется.
- Channel tag: `c_test5590=<canonical_channel_slug>` вместо `c=...`. - Channel tag: `c_test5590=<canonical_channel_slug>` вместо `c=...`.
- Фиксированные signed indexes: `t=tech`, `t=conn`, `t=prof`.
- Удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`. - Удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`.
- Схема PostgreSQL: v25. - Схема PostgreSQL: v25.
+1 -1
View File
@@ -14,7 +14,7 @@
```text ```text
User User
-> создаёт Frame v1 -> создаёт Frame v1
-> tags: App=test5590, при канале c_test5590=<slug> -> tags: App=test5590; t=tech/conn/prof для фиксированных потоков; при канале c_test5590=<slug>
-> Ed25519 подписывает ANS-104 deep-hash -> Ed25519 подписывает ANS-104 deep-hash
-> готовый DataItem -> готовый DataItem
-> AddBlock -> AddBlock
+203
View File
@@ -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`.
@@ -55,7 +55,7 @@
- `type=1` — `TEXT` - `type=1` — `TEXT`
- `type=2` — `REACTION` - `type=2` — `REACTION`
- `type=3` — `CONNECTION` - `type=3` — `CONNECTION`
- `type=4` — `USER_PARAM` - `type=4` — `USER_PROFILE`
## Верхнеуровневые типы первой итерации ## Верхнеуровневые типы первой итерации
@@ -65,7 +65,7 @@
- `1` — `TEXT` - `1` — `TEXT`
- `2` — `REACTION` - `2` — `REACTION`
- `3` — `CONNECTION` - `3` — `CONNECTION`
- `4` — `USER_PARAM` - `4` — `USER_PROFILE`
- `5` — `STATUS_ACTION` - `5` — `STATUS_ACTION`
Отдельно: Отдельно:
@@ -652,7 +652,7 @@
| 1 | TEXT | старый код, новое расширенное смысловое описание | | 1 | TEXT | старый код, новое расширенное смысловое описание |
| 2 | REACTION | старый | | 2 | REACTION | старый |
| 3 | CONNECTION | старый | | 3 | CONNECTION | старый |
| 4 | USER_PARAM | старый | | 4 | USER_PROFILE | старый |
| 5 | STATUS_ACTION | первая итерация | | 5 | STATUS_ACTION | первая итерация |
| 6 | CHANNEL_MEMBERSHIP | вынесено в отдельный отложенный черновик | | 6 | CHANNEL_MEMBERSHIP | вынесено в отдельный отложенный черновик |
+351 -234
View File
@@ -37,7 +37,7 @@ import {
saveEncryptedUserSecrets, saveEncryptedUserSecrets,
saveSessionMaterial, saveSessionMaterial,
} from './key-vault.js'; } 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'; import { defaultServerWs } from '../deploy-config.js';
const BCH_SUFFIX = '001'; const BCH_SUFFIX = '001';
@@ -48,10 +48,12 @@ const MSG_TYPE_TECH = 0;
const MSG_TYPE_TEXT = 1; const MSG_TYPE_TEXT = 1;
const MSG_TYPE_REACTION = 2; const MSG_TYPE_REACTION = 2;
const MSG_TYPE_CONNECTION = 3; const MSG_TYPE_CONNECTION = 3;
const MSG_TYPE_USER_PROFILE = 4;
const MSG_TYPE_STATUS_ACTION = 5; const MSG_TYPE_STATUS_ACTION = 5;
const MSG_SUBTYPE_TECH_HEADER = 0; const MSG_SUBTYPE_TECH_HEADER = 0;
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1; const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
const MSG_SUBTYPE_TECH_FORK = 2;
const MSG_SUBTYPE_TEXT_POST = 10; const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_EDIT_POST = 11; const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
const MSG_SUBTYPE_TEXT_REPLY = 20; 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 keyBytes = utf8Bytes(String(key || ''));
const valueBytes = utf8Bytes(String(value || '')); const valueBytes = utf8Bytes(String(value || ''));
const prevHashBytes = hexToBytes(prevLineHashHex); const prevHashBytes = hexToBytes(prevProfileBlockHashHex);
if (!keyBytes.length || !valueBytes.length) throw new Error('Пустые key/value для блока параметра'); if (!keyBytes.length || !valueBytes.length) throw new Error('Пустые key/value для блока профиля');
if (prevHashBytes.length !== 32) throw new Error('prevLineHash должен быть 32 байта'); 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( return concatBytes(
int32Bytes(lineCode), int32Bytes(prevProfileBlockNumber),
int32Bytes(prevLineNumber),
prevHashBytes, prevHashBytes,
int32Bytes(thisLineNumber), int32Bytes(profileSequence),
int16Bytes(keyBytes.length), int16Bytes(keyBytes.length),
keyBytes, keyBytes,
int16Bytes(valueBytes.length), int16Bytes(valueBytes.length),
@@ -653,15 +656,17 @@ function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
} }
function makeConnectionBodyBytes({ function makeConnectionBodyBytes({
lineCode = 0, prevLineNumber = -1, prevLineHashHex = ZERO64, thisLineNumber = -1, prevConnectionBlockNumber, prevConnectionBlockHashHex, connectionSequence,
toLogin, toForkNumber, toBlockNumber, toBlockHashHex, toLogin, toForkNumber, toBlockNumber, toBlockHashHex,
}) { }) {
const loginBytes = targetLoginBytes(toLogin); const loginBytes = targetLoginBytes(toLogin);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'connection'); const forkNumber = normalizeTargetForkNumber(toForkNumber, 'connection');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'connection'); const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'connection');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, '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( 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, int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes,
); );
} }
@@ -674,7 +679,7 @@ function targetLoginBytes(value) {
function normalizeTargetForkNumber(value, actionName = 'target') { function normalizeTargetForkNumber(value, actionName = 'target') {
const n = Number(value); 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; return n;
} }
@@ -884,6 +889,51 @@ function resolveLatestLineStep(message) {
return null; 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 }) { function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
const prevHashBytes = hexToBytes(normalizeHex32(prevBlockHashHex)); const prevHashBytes = hexToBytes(normalizeHex32(prevBlockHashHex));
const body = bodyBytes || new Uint8Array(0); const body = bodyBytes || new Uint8Array(0);
@@ -919,6 +969,8 @@ export class AuthService {
this.eventListeners = new Map(); this.eventListeners = new Map();
this.wsEventUnsubscribers = new Map(); this.wsEventUnsubscribers = new Map();
this.headerHashCache = new Map(); this.headerHashCache = new Map();
this.lineTailCache = new Map();
this.lineWriteQueues = new Map();
this.writeLocks = new Map(); this.writeLocks = new Map();
this.passwordKeyBundleCache = new Map(); this.passwordKeyBundleCache = new Map();
this.passwordKeyBundleInFlight = new Map(); this.passwordKeyBundleInFlight = new Map();
@@ -951,6 +1003,8 @@ export class AuthService {
this.serverUrl = normalized; this.serverUrl = normalized;
this.ws = new WsJsonClient(this.serverUrl); this.ws = new WsJsonClient(this.serverUrl);
this.headerHashCache = new Map(); this.headerHashCache = new Map();
this.lineTailCache = new Map();
this.lineWriteQueues = new Map();
this.writeLocks.clear(); this.writeLocks.clear();
this.bindRegisteredEventsToCurrentWs(); this.bindRegisteredEventsToCurrentWs();
if (clearSessionContext) this.clearActiveSessionContext(); if (clearSessionContext) this.clearActiveSessionContext();
@@ -1142,10 +1196,8 @@ export class AuthService {
async createSessionFromImportedSecrets(login, secrets) { async createSessionFromImportedSecrets(login, secrets) {
const cleanLogin = (login || '').trim(); const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('В QR-коде нет логина'); if (!cleanLogin) throw new Error('В QR-коде нет логина');
const clientKey = String(secrets?.clientKey || '').trim(); const clientKey = String(secrets?.clientKey || secrets?.clientKey || '').trim();
const blockchainKey = String(secrets?.blockchainKey || '').trim(); if (!clientKey) throw new Error('В QR-коде нет client key для входа');
if (!clientKey) throw new Error('В QR-коде нет ключа доступа для входа');
if (!blockchainKey) throw new Error('В QR-коде нет ключа блокчейна');
const privateKey = await importPkcs8Ed25519(clientKey); const privateKey = await importPkcs8Ed25519(clientKey);
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(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 = {}; let currentSecrets = {};
try { try {
const loaded = await loadEncryptedUserSecrets(login, storagePwd); 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 = { const secrets = {
...currentSecrets, ...currentSecrets,
clientKey, clientKey: keyBundle.clientPair.privatePkcs8B64,
blockchainKey,
}; };
// SAWD-v1 wallet зависит от blockchain key. При смене ключа старый кэш if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
// нельзя переносить в новую identity: он будет лениво выведен заново. if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
if (String(currentSecrets?.blockchainKey || '').trim() !== blockchainKey) {
delete secrets.arweaveWallet;
}
delete secrets.rootKey;
await saveEncryptedUserSecrets(login, storagePwd, secrets); await saveEncryptedUserSecrets(login, storagePwd, secrets);
} }
@@ -1773,6 +1814,8 @@ export class AuthService {
} }
const ansTags = [{ name: 'App', value: 'test5590' }]; const ansTags = [{ name: 'App', value: 'test5590' }];
const typeIndexTag = typeIndexTagValue(msgType);
if (typeIndexTag) ansTags.push({ name: 't', value: typeIndexTag });
const cleanChannelSlug = String(channelSlug || '').trim(); const cleanChannelSlug = String(channelSlug || '').trim();
if (cleanChannelSlug) ansTags.push({ name: 'c_test5590', value: cleanChannelSlug }); if (cleanChannelSlug) ansTags.push({ name: 'c_test5590', value: cleanChannelSlug });
@@ -1990,53 +2033,17 @@ export class AuthService {
const selector = channel || {}; const selector = channel || {};
const owner = String(selector?.ownerBlockchainName || '').trim(); const owner = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber); 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 user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
if (!owner || !Number.isFinite(root) || root < 0) throw new Error('Invalid channel selector'); const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, 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 bodyBytes = makeTextRepostBodyBytes({ const bodyBytes = makeTextRepostBodyBytes({
lineCode: root, lineCode: tail.lineCode,
prevLineNumber, prevLineNumber: tail.prevLineNumber,
prevLineHashHex, prevLineHashHex: tail.prevLineHashHex,
thisLineNumber, thisLineNumber: tail.thisLineNumber,
toLogin: target.login, toLogin: target.login,
toForkNumber: target.forkNumber, toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber, toBlockNumber: target.blockNumber,
@@ -2044,15 +2051,17 @@ export class AuthService {
text: cleanText, text: cleanText,
}); });
return this.addBlockSigned({ const payload = await this.addBlockSigned({
login: cleanLogin, login: cleanLogin,
storagePwd, storagePwd,
msgType: MSG_TYPE_TEXT, msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_REPOST, msgSubType: MSG_SUBTYPE_TEXT_REPOST,
msgVersion: 1, msgVersion: 1,
bodyBytes, 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 cleanLogin = String(login || '').trim();
const cleanText = String(text || '').trim(); const cleanText = String(text || '').trim();
const target = normalizeMessageRefTarget(message, 'edit'); 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) { if (isChannelPost) {
const selector = channel || {}; const selector = channel || {};
const ownerBlockchainName = String(selector?.ownerBlockchainName || target.blockchainName || '').trim(); const ownerBlockchainName = String(selector?.ownerBlockchainName || target.blockchainName || '').trim();
const lineCode = Number(selector?.channelRootBlockNumber); 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 for edit'); throw new Error('Invalid channel selector for edit');
} }
const lockKey = `channel-line:${cleanLogin}:${ownerBlockchainName}:${lineCode}`;
let channelSlug = toCanonicalChannelSlug(String(selector?.channelName || selector?.slug || '')); return this.runLineWriteLocked(lockKey, async () => {
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64); const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
if (rootHashHex === ZERO64 || !channelSlug) { const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, ownerBlockchainName); const tail = await this.resolveChannelLineTail({
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode); login: cleanLogin,
if (rootChannel) { blockchainName,
if (rootHashHex === ZERO64) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64); channel: {
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName); ...selector,
}
}
let prevLineNumber = lineCode;
let prevLineHashHex = rootHashHex;
let thisLineNumber = 1;
try {
const latestPayload = await this.getChannelMessages({
ownerBlockchainName, ownerBlockchainName,
channelRootBlockNumber: lineCode, 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({ const bodyBytes = makeTextEditPostBodyBytes({
lineCode, lineCode: tail.lineCode,
prevLineNumber, prevLineNumber: tail.prevLineNumber,
prevLineHashHex, prevLineHashHex: tail.prevLineHashHex,
thisLineNumber, thisLineNumber: tail.thisLineNumber,
toLogin: target.login,
toBlockNumber: target.blockNumber, toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash, toBlockHashHex: target.blockHash,
text: cleanText, text: cleanText,
}); });
const payload = await this.addBlockSigned({
return this.addBlockSigned({
login: cleanLogin, login: cleanLogin,
storagePwd, storagePwd,
msgType: MSG_TYPE_TEXT, msgType: MSG_TYPE_TEXT,
msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST, msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST,
msgVersion: 1, msgVersion: 1,
bodyBytes, 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({ const bodyBytes = makeTextEditReplyBodyBytes({
toBlockNumber: target.blockNumber, toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash, toBlockHashHex: target.blockHash,
text: cleanText, text: cleanText,
}); });
return this.addBlockSigned({ return this.addBlockSigned({
login: cleanLogin, login: cleanLogin,
storagePwd, storagePwd,
@@ -2196,9 +2180,6 @@ export class AuthService {
throw new Error('Invalid target block number'); throw new Error('Invalid target block number');
} }
const seedHash = normalizeHex32(targetBlockHashHex, ZERO64); const seedHash = normalizeHex32(targetBlockHashHex, ZERO64);
const key = `${unfollow ? 'unfollow-channel' : 'follow-channel'}:${cleanLogin}:${cleanTargetBch}:${cleanTargetBlockNumber}:${seedHash}`;
return this.runWriteLocked(key, async () => {
let targetHashHex = seedHash; let targetHashHex = seedHash;
if (targetHashHex === ZERO64) { if (targetHashHex === ZERO64) {
targetHashHex = cleanTargetBlockNumber === 0 targetHashHex = cleanTargetBlockNumber === 0
@@ -2206,26 +2187,15 @@ export class AuthService {
: await this.getBlockHashByNumber(cleanTargetBch, cleanTargetBlockNumber); : await this.getBlockHashByNumber(cleanTargetBch, cleanTargetBlockNumber);
} }
const bodyBytes = makeConnectionBodyBytes({ return this.addBlockConnectionTarget({
lineCode: 0, login: cleanLogin,
prevLineNumber: -1, storagePwd,
prevLineHashHex: ZERO64, subType: unfollow ? MSG_SUBTYPE_CONNECTION_UNFOLLOW : MSG_SUBTYPE_CONNECTION_FOLLOW,
thisLineNumber: -1,
toLogin: cleanTargetLogin, toLogin: cleanTargetLogin,
toForkNumber: forkNumberFromBlockchainNameValue(cleanTargetBch), toForkNumber: forkNumberFromBlockchainNameValue(cleanTargetBch),
toBlockNumber: cleanTargetBlockNumber, toBlockNumber: cleanTargetBlockNumber,
toBlockHashHex: targetHashHex, 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,
});
});
} }
async getBlockHashByNumber(blockchainName, blockNumber) { async getBlockHashByNumber(blockchainName, blockNumber) {
@@ -2270,6 +2240,123 @@ export class AuthService {
return headerHash; 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) { async listOwnChannelsForBlockchain(login, blockchainName) {
const feed = await this.listSubscriptionsFeed(login, 500); const feed = await this.listSubscriptionsFeed(login, 500);
const own = feed?.ownedChannels || []; const own = feed?.ownedChannels || [];
@@ -2314,34 +2401,11 @@ export class AuthService {
: normalizeChannelDescription(channelDescription); : normalizeChannelDescription(channelDescription);
const channelSlug = toCanonicalChannelSlug(cleanChannelName); const channelSlug = toCanonicalChannelSlug(cleanChannelName);
const typeVersion = Number(channelTypeVersion); const typeVersion = Number(channelTypeVersion);
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`; const key = `tech-line:${cleanLogin}`;
return this.runWriteLocked(key, async () => { return this.runLineWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const userLastGlobalNumber = Number(user?.serverLastGlobalNumber); const tail = await this.resolveFixedLineTail({ blockchainName, kind: 'tech' });
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 payload = await this.addBlockSigned({ const payload = await this.addBlockSigned({
login: cleanLogin, login: cleanLogin,
@@ -2352,15 +2416,16 @@ export class AuthService {
channelSlug, channelSlug,
bodyBytes: makeCreateChannelBodyBytes({ bodyBytes: makeCreateChannelBodyBytes({
lineCode: 0, lineCode: 0,
prevLineNumber, prevLineNumber: tail.prevLineNumber,
prevLineHashHex, prevLineHashHex: tail.prevLineHashHex,
thisLineNumber, thisLineNumber: tail.thisLineNumber,
channelName: cleanChannelName, channelName: cleanChannelName,
channelDescription: cleanChannelDescription, channelDescription: cleanChannelDescription,
channelType: typeCode, channelType: typeCode,
channelTypeVersion: typeVersion, channelTypeVersion: typeVersion,
}), }),
}); });
this.rememberLineTail(blockchainName, 'tech', payload, tail.thisLineNumber);
const selector = { const selector = {
ownerBlockchainName: blockchainName, ownerBlockchainName: blockchainName,
@@ -2381,7 +2446,7 @@ export class AuthService {
const selector = channel || {}; const selector = channel || {};
const ownerBlockchainName = String(selector?.ownerBlockchainName || '').trim(); const ownerBlockchainName = String(selector?.ownerBlockchainName || '').trim();
const lineCode = Number(selector?.channelRootBlockNumber); 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'); throw new Error('Invalid channel selector');
} }
if (ownerBlockchainName !== blockchainName) { if (ownerBlockchainName !== blockchainName) {
@@ -2398,52 +2463,70 @@ export class AuthService {
if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName); if (!channelSlug) channelSlug = toCanonicalChannelSlug(rootChannel.channelName);
} }
if (!channelSlug) throw new Error('Cannot resolve canonical channel slug'); if (!channelSlug) throw new Error('Cannot resolve canonical channel slug');
if (rootHashHex === ZERO64) throw new Error('Cannot resolve channel root hash');
let prevLineNumber = lineCode; const cacheKey = `${blockchainName}|channel:${lineCode}`;
let prevLineHashHex = rootHashHex; const cached = this.lineTailCache.get(cacheKey);
let thisLineNumber = 0; if (cached) {
try {
const latestPayload = await this.getChannelMessages({
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
}
return { return {
ownerBlockchainName, ownerBlockchainName,
lineCode, lineCode,
rootHashHex, rootHashHex,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelSlug, 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,
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 }) { async addBlockTextPost({ login, channel, text, storagePwd, msgSubType = MSG_SUBTYPE_TEXT_POST }) {
const cleanLogin = (login || '').trim(); const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login'); if (!cleanLogin) throw new Error('Missing login');
@@ -2462,9 +2545,9 @@ export class AuthService {
const selector = channel || {}; const selector = channel || {};
const owner = String(selector?.ownerBlockchainName || '').trim(); const owner = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber); 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 user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
@@ -2487,6 +2570,7 @@ export class AuthService {
bodyBytes, bodyBytes,
channelSlug: tail.channelSlug, channelSlug: tail.channelSlug,
}); });
this.rememberChannelLineTail(blockchainName, tail.lineCode, payload, tail.thisLineNumber);
return { return {
...payload, ...payload,
@@ -2506,9 +2590,9 @@ export class AuthService {
const owner = String(selector?.ownerBlockchainName || '').trim(); const owner = String(selector?.ownerBlockchainName || '').trim();
const root = Number(selector?.channelRootBlockNumber); const root = Number(selector?.channelRootBlockNumber);
const metaText = composeChannelMetaText({ title, description, avatar }); 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 user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector }); const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
@@ -2529,6 +2613,7 @@ export class AuthService {
bodyBytes, bodyBytes,
channelSlug: tail.channelSlug, channelSlug: tail.channelSlug,
}); });
this.rememberChannelLineTail(blockchainName, tail.lineCode, payload, tail.thisLineNumber);
return { return {
...payload, ...payload,
@@ -3161,22 +3246,66 @@ export class AuthService {
if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.'); if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.');
if (!cleanValue) throw new Error('Значение параметра не может быть пустым.'); if (!cleanValue) throw new Error('Значение параметра не может быть пустым.');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.'); if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
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({ const bodyBytes = makeUserParamBodyBytes({
lineCode: 0, prevProfileBlockNumber: tail.prevLineNumber,
prevLineNumber: -1, prevProfileBlockHashHex: tail.prevLineHashHex,
prevLineHashHex: ZERO_HASH_HEX, profileSequence: tail.thisLineNumber,
thisLineNumber: -1,
key: cleanParam, key: cleanParam,
value: cleanValue, value: cleanValue,
}); });
return this.addBlockSigned({ const payload = await this.addBlockSigned({
login: cleanLogin, login: cleanLogin,
storagePwd, storagePwd,
msgType: 4, msgType: MSG_TYPE_USER_PROFILE,
msgSubType: 1, msgSubType: 1,
msgVersion: 1, msgVersion: 1,
bodyBytes, bodyBytes,
}); });
this.rememberLineTail(blockchainName, 'profile', payload, tail.thisLineNumber);
return payload;
});
}
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;
});
} }
async addBlockConnection({ login, toLogin, subType, storagePwd }) { async addBlockConnection({ login, toLogin, subType, storagePwd }) {
@@ -3190,9 +3319,6 @@ export class AuthService {
throw new Error('Нельзя создать связь на самого себя.'); throw new Error('Нельзя создать связь на самого себя.');
} }
const user = await this.getUser(cleanLogin);
if (user?.exists === false) throw new Error('Текущий пользователь не найден.');
const targetUser = await this.getUser(cleanToLogin); const targetUser = await this.getUser(cleanToLogin);
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.'); if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
const targetBlockchainName = String(targetUser.blockchainName || '').trim(); const targetBlockchainName = String(targetUser.blockchainName || '').trim();
@@ -3200,24 +3326,15 @@ export class AuthService {
if (!targetForkNumber) throw new Error('Не удалось определить fork пользователя цели.'); if (!targetForkNumber) throw new Error('Не удалось определить fork пользователя цели.');
const targetHeaderHash = await this.resolveHeaderHashForBlockchain(targetBlockchainName); const targetHeaderHash = await this.resolveHeaderHashForBlockchain(targetBlockchainName);
if (targetHeaderHash === ZERO64) throw new Error('HEADER пользователя цели не найден.'); if (targetHeaderHash === ZERO64) throw new Error('HEADER пользователя цели не найден.');
const bodyBytes = makeConnectionBodyBytes({ return this.addBlockConnectionTarget({
lineCode: 0, login: cleanLogin,
prevLineNumber: -1, storagePwd,
prevLineHashHex: ZERO_HASH_HEX, subType: cleanSubType,
thisLineNumber: -1,
toLogin: cleanToLogin, toLogin: cleanToLogin,
toForkNumber: targetForkNumber, toForkNumber: targetForkNumber,
toBlockNumber: 0, toBlockNumber: 0,
toBlockHashHex: targetHeaderHash, toBlockHashHex: targetHeaderHash,
}); });
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: 3,
msgSubType: cleanSubType,
msgVersion: 1,
bodyBytes,
});
} }
+48 -12
View File
@@ -37,12 +37,30 @@ function readFrameTimestampMs(frame) {
const seconds = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getBigInt64(42, false); const seconds = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getBigInt64(42, false);
return Number(seconds * 1000n); 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 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 c=TE.encode(normalizeComment(comment)); if(c.length>1024) throw new Error('Комментарий больше 1024 UTF-8 байт');
const discarded=Number(parentTipBlock)-Number(forkPointBlock); const discarded=Number(parentTipBlock)-Number(forkPointBlock);
if(discarded<0) throw new Error('Некорректная точка fork'); 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() }) { function makeFrame({ prevHash, blockNumber, type, subType, version=1, body, timestampMs=Date.now() }) {
const bodyBytes=body || new Uint8Array(0); 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'); 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=()=>{} }) { async copyCandidateChain({ rotation, newBundle, onProgress=()=>{} }) {
const cutoff=Number(rotation.forkFromBlock); const source=String(rotation.sourceBlockchainName||''); const cutoff=Number(rotation.forkFromBlock); const source=String(rotation.sourceBlockchainName||'');
if(!source || !Number.isInteger(cutoff) || cutoff<0) throw new Error('Некорректное состояние ротации'); 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 tipSource=tipBlock===cutoff ? forkSource : await this.getBlock(source,tipBlock);
const forkParsed=parseAns104DataItem(base64ToBytes(forkSource.blockBytesB64)); const forkParsed=parseAns104DataItem(base64ToBytes(forkSource.blockBytesB64));
const tipParsed=parseAns104DataItem(base64ToBytes(tipSource.blockBytesB64)); const tipParsed=parseAns104DataItem(base64ToBytes(tipSource.blockBytesB64));
const techTail=await this.#resolveTechLineTail(source,cutoff);
const forkBody=makeForkBody({ const forkBody=makeForkBody({
lineCode:0,
prevLineNumber:techTail.prevLineNumber,
prevLineHash:techTail.prevLineHash,
lineSequence:techTail.lineSequence,
oldBlockchainKeyB64:rotation.oldBlockchainKey, oldBlockchainKeyB64:rotation.oldBlockchainKey,
forkPointBlock:cutoff, forkPointBlock:cutoff,
forkPointHash:rotation.forkFromHash, forkPointHash:rotation.forkFromHash,
@@ -111,9 +152,9 @@ export class KeyRotationClient {
reasonCode:rotation.reasonCode, reasonCode:rotation.reasonCode,
comment:rotation.comment, 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 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) }); await this.addCandidate({ blockNumber:cutoff+1, prevBlockHash:rotation.forkFromHash, blockBytesB64:bytesToBase64(techRaw) });
onProgress({ phase:'copy', current:total, total }); onProgress({ phase:'copy', current:total, total });
} }
@@ -160,17 +201,12 @@ export class KeyRotationClient {
} }
const state=await this.notifyPdaRotation(signature); const state=await this.notifyPdaRotation(signature);
if(storagePwd) { if(storagePwd) {
await updateEncryptedUserSecrets(login, storagePwd, (current)=>{ await updateEncryptedUserSecrets(login, storagePwd, (current)=>({
const next={
...(current||{}), ...(current||{}),
rootKey:newBundle.rootPair.privatePkcs8B64,
blockchainKey:newBundle.blockchainPair.privatePkcs8B64, blockchainKey:newBundle.blockchainPair.privatePkcs8B64,
clientKey:newBundle.clientPair.privatePkcs8B64, clientKey:newBundle.clientPair.privatePkcs8B64,
}; }));
// Arweave SAWD-v1 привязан к blockchain key: после ротации старый JWK-cache недействителен.
delete next.arweaveWallet;
delete next.rootKey;
return next;
});
} }
return state; return state;
} }
+1 -1
View File
@@ -2430,7 +2430,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
const gateway = baseUrl(gatewayBaseUrl); const gateway = baseUrl(gatewayBaseUrl);
const ownerAddress = await arweaveOwnerAddressFromBlockchainKey(blockchainKey); const ownerAddress = await arweaveOwnerAddressFromBlockchainKey(blockchainKey);
const tags = [{ name: 'App', values: [SHINE_ARWEAVE_APP_TAG] }]; 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}}}}}`; 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 after = null;
let page = 0; let page = 0;