Compare commits

..
169 changed files with 6923 additions and 3321 deletions
@@ -3,7 +3,9 @@ package server.archive;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import shine.db.dao.BlocksDAO; import shine.db.dao.BlocksDAO;
import shine.db.dao.KeyRotationCandidateBlocksDAO;
import shine.db.entities.BlockEntry; import shine.db.entities.BlockEntry;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -14,6 +16,7 @@ public final class ArweaveBlockPublisherService {
private final ArweaveBlocksConfig cfg; private final ArweaveBlocksConfig cfg;
private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
private final KeyRotationCandidateBlocksDAO candidateBlocksDAO = KeyRotationCandidateBlocksDAO.getInstance();
private final ArweaveL1Uploader arweaveUploader; private final ArweaveL1Uploader arweaveUploader;
private final TurboDataItemUploader turboUploader; private final TurboDataItemUploader turboUploader;
@@ -25,13 +28,57 @@ public final class ArweaveBlockPublisherService {
public int runCycle() throws Exception { public int runCycle() throws Exception {
if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0; if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0;
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
if (candidates.isEmpty()) return 0; int maxItems = cfg.publishMaxItems();
return switch (cfg.publishMode()) { int attempted = 0;
int published = 0;
// Ротация приоритетна: пользователь уже заблокирован для обычной записи и ждёт завершения копирования.
List<KeyRotationCandidateBlockEntry> rotationCandidates = candidateBlocksDAO.listPendingArweave(maxItems);
if (!rotationCandidates.isEmpty()) {
attempted += rotationCandidates.size();
published += switch (cfg.publishMode()) {
case TURBO -> publishTurboRotation(rotationCandidates);
case ARWEAVE -> publishDirectArweaveRotation(rotationCandidates);
case NONE -> 0;
};
}
int remaining = Math.max(0, maxItems - attempted);
if (remaining == 0) return published;
List<BlockEntry> candidates = blocksDAO.listPendingArweave(remaining);
if (candidates.isEmpty()) return published;
published += switch (cfg.publishMode()) {
case TURBO -> publishTurbo(candidates); case TURBO -> publishTurbo(candidates);
case ARWEAVE -> publishDirectArweave(candidates); case ARWEAVE -> publishDirectArweave(candidates);
case NONE -> 0; case NONE -> 0;
}; };
return published;
}
private int publishTurboRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
int published = 0;
Exception firstFailure = null;
for (KeyRotationCandidateBlockEntry e : candidates) {
byte[] raw = e.getBlockBytes();
byte[] id = e.getDataItemId();
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
try {
TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id);
candidateBlocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis());
published++;
log.debug("Turbo published key-rotation DataItem {} chain={} block={}",
result.dataItemId(), e.getCandidateBlockchainName(), e.getBlockNumber());
} catch (Exception ex) {
if (firstFailure == null) firstFailure = ex;
log.warn("Turbo key-rotation publish failed: chain={} block={} bytes={} error={}",
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length, ex.getMessage());
}
}
if (published > 0) log.info("Published {} SHiNE key-rotation DataItems through Turbo", published);
if (published == 0 && firstFailure != null) throw firstFailure;
return published;
} }
private int publishTurbo(List<BlockEntry> candidates) throws Exception { private int publishTurbo(List<BlockEntry> candidates) throws Exception {
@@ -57,6 +104,40 @@ public final class ArweaveBlockPublisherService {
return published; return published;
} }
private int publishDirectArweaveRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
List<byte[]> items = new ArrayList<>();
List<byte[]> ids = new ArrayList<>();
long estimated = 32;
for (KeyRotationCandidateBlockEntry e : candidates) {
byte[] raw = e.getBlockBytes();
byte[] id = e.getDataItemId();
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
long next = estimated + 64L + raw.length;
if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break;
if (next > cfg.publishMaxBundleBytes()) {
log.error("One key-rotation DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}",
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length);
continue;
}
items.add(raw);
ids.add(id);
estimated = next;
}
if (items.isEmpty()) return 0;
byte[] bundle = Ans104Bundle.write(items);
List<ArweaveL1Uploader.Tag> rootTags = List.of(
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0")
);
ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags);
candidateBlocksDAO.markArweavePublished(ids, System.currentTimeMillis());
log.info("Published {} SHiNE key-rotation DataItems in direct Arweave root tx {} (bundle={} bytes)",
items.size(), result.txId(), bundle.length);
return items.size();
}
private int publishDirectArweave(List<BlockEntry> candidates) throws Exception { private int publishDirectArweave(List<BlockEntry> candidates) throws Exception {
List<byte[]> items = new ArrayList<>(); List<byte[]> items = new ArrayList<>();
List<byte[]> ids = new ArrayList<>(); List<byte[]> ids = new ArrayList<>();
@@ -25,6 +25,10 @@ public final class BodyRecordParser {
&& (v == (CreateChannelBody.VER & 0xFFFF))) { && (v == (CreateChannelBody.VER & 0xFFFF))) {
return new CreateChannelBody(subType, version, bodyBytes).check(); return new CreateChannelBody(subType, version, bodyBytes).check();
} }
if (st == (ForkBody.SUBTYPE & 0xFFFF)
&& (v == (ForkBody.VER & 0xFFFF))) {
return new ForkBody(subType, version, bodyBytes).check();
}
throw new IllegalArgumentException( throw new IllegalArgumentException(
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st) String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
); );
@@ -35,6 +35,8 @@ public final class MsgSubType {
/** HeaderBody: subType всегда 0 (compat). */ /** HeaderBody: subType всегда 0 (compat). */
public static final short HEADER_COMPAT = 0; public static final short HEADER_COMPAT = 0;
public static final short TECH_CREATE_CHANNEL = 1; public static final short TECH_CREATE_CHANNEL = 1;
/** Новый fork/ротация ключей: ссылка на родительскую цепочку и точку отката. */
public static final short TECH_FORK = 2;
/* ===================== TEXT (msg_type=1) ===================== */ /* ===================== TEXT (msg_type=1) ===================== */
@@ -53,7 +55,7 @@ public final class MsgSubType {
/** /**
* REPLY — ответ на сообщение. * REPLY — ответ на сообщение.
* НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32). * НЕ в линии. Имеет target (toLogin + blockNumber + hash32).
* Может указывать на чужой блокчейн/чужую линию/чужой канал. * Может указывать на чужой блокчейн/чужую линию/чужой канал.
*/ */
public static final short TEXT_REPLY = 20; public static final short TEXT_REPLY = 20;
@@ -69,7 +71,7 @@ public final class MsgSubType {
/** /**
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала. * REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария. * Имеет hasLine + target (toLogin + toBlockGlobalNumber + toBlockHash32) + текст комментария.
*/ */
public static final short TEXT_REPOST = 50; public static final short TEXT_REPOST = 50;
@@ -1,31 +1,32 @@
package blockchain.body; package blockchain.body;
import utils.blockchain.BlockchainNameUtil;
/** /**
* BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля). * BodyHasTarget — дополнительный интерфейс для body, которые ссылаются на логическую цель.
* *
* Новое правило: * Актуальное правило target:
* - toLogin НЕ храним в байтах блока. * - в подписываемых байтах хранится login цели;
* - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN". * - номер fork/blockchainName в target не хранится;
* - идентичность цели задаётся как login + blockNumber + blockHash.
* *
* Все методы могут возвращать null. * Это позволяет одной и той же логической записи сохранять ссылки после fork,
* если её номер и SHA-256 hash остались прежними.
*/ */
public interface BodyHasTarget { public interface BodyHasTarget {
/** login цели (nullable). Вычисляется из toBchName(). */ /** login цели. Актуальные runtime-body обязаны переопределять этот метод. */
default String toLogin() { default String toLogin() { return null; }
String bch = toBchName();
if (bch == null) return null;
return BlockchainNameUtil.loginFromBlockchainName(bch);
}
/** blockchainName цели (nullable). */ /**
String toBchName(); * Legacy source-level accessor. В новом подписанном target blockchainName не хранится.
* Оставлен временно только чтобы старые вспомогательные классы компилировались;
* runtime-логика не должна использовать его для новых блоков.
*/
@Deprecated
default String toBchName() { return null; }
/** globalNumber цели (nullable). */ /** globalNumber цели (nullable только если конкретный subtype не содержит target). */
Integer toBlockGlobalNumber(); Integer toBlockGlobalNumber();
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */ /** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
byte[] toBlockHashBytes(); byte[] toBlockHashBytes();
} }
@@ -1,7 +1,6 @@
package blockchain.body; package blockchain.body;
import blockchain.MsgSubType; import blockchain.MsgSubType;
import utils.blockchain.BlockchainNameUtil;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.ByteOrder; import java.nio.ByteOrder;
@@ -12,96 +11,38 @@ import java.util.Objects;
/** /**
* ConnectionBody — type=3, ver=1 (в заголовке блока). * ConnectionBody — type=3, ver=1 (в заголовке блока).
* *
* subType (в заголовке блока) как MsgSubType: * bodyBytes (BigEndian):
* FRIEND=10, UNFRIEND=11
* CONTACT=20, UNCONTACT=21
* FOLLOW=30, UNFOLLOW=31
* SPOUSE=40, UNSPOUSE=41
* PARENT=50, UNPARENT=51
* CHILD=52, UNCHILD=53
* SIBLING=54, UNSIBLING=55
* FRIEND=14, UNFRIEND=15
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
*
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
* [4] lineCode * [4] lineCode
* [4] prevLineNumber * [4] prevLineNumber
* [32] prevLineHash32 * [32] prevLineHash32
* [4] thisLineNumber * [4] thisLineNumber
* * [1] toLoginLen (uint8)
* [1] toBlockchainNameLen (uint8) * [N] toLogin UTF-8
* [N] toBlockchainName UTF-8 * [4] toBlockGlobalNumber (int32)
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes) * [32] toBlockHash32 (raw 32 bytes)
* *
* toLogin вычисляется автоматически из toBlockchainName: * Номер fork/blockchainName в target не хранится.
* toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName)
*/ */
/**
* =========================================================================
* ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов)
* =========================================================================
*
* Термины:
* - ROOT линии/канала = блок, который "начинает" линию:
* * для канала "0" root = HEADER (blockNumber=0)
* * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока)
*
* 1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*):
* FRIEND / CONTACT -> цель ВСЕГДА HEADER пользователя:
* toBlockNumber = 0
* toBlockHash32 = hash32(HEADER цели)
*
* 2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE):
* FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER):
* toBlockNumber = 0
* toBlockHash32 = hash32(HEADER цели)
*
* FOLLOW/подписка на конкретный канал пользователя ->
* цель = ROOT этого канала:
* - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER)
* - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL),
* toBlockHash32=hash32(CREATE_CHANNEL)
*
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
*
* Зачем так:
* - связи и подписки всегда стабильны и не ломаются при новых постах,
* - один понятный инвариант: "подписка всегда указывает на root линии".
* =========================================================================
*/
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;
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 lineCode; public final int lineCode;
public final int prevLineNumber; public final int prevLineNumber;
public final byte[] prevLineHash32; public final byte[] prevLineHash32;
public final int thisLineNumber; public final int thisLineNumber;
// payload public final String toLogin;
public final String toBlockchainName;
public final int toBlockGlobalNumber; public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; public final byte[] toBlockHash32;
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.subType = subType;
this.version = version; this.version = version;
@@ -111,34 +52,25 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
if (!isValidSubType(this.subType)) { if (!isValidSubType(this.subType)) {
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF)); throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
} }
if (bodyBytes.length < 4 + 4 + 32 + 4 + 1 + 1 + 4 + 32) {
// минимум:
// lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32]
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) {
throw new IllegalArgumentException("ConnectionBody too short"); throw new IllegalArgumentException("ConnectionBody 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.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt(); this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32]; this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32); bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt(); this.thisLineNumber = bb.getInt();
int bchLen = Byte.toUnsignedInt(bb.get()); int loginLen = Byte.toUnsignedInt(bb.get());
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short"); if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
byte[] bchBytes = new byte[bchLen];
bb.get(bchBytes);
this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8);
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt(); this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32]; this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32); bb.get(this.toBlockHash32);
@@ -150,39 +82,68 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
byte[] prevLineHash32, byte[] prevLineHash32,
int thisLineNumber, int thisLineNumber,
short subType, short subType,
String toBlockchainName, String toLogin,
int toBlockGlobalNumber, int toBlockGlobalNumber,
byte[] toBlockHash32) { byte[] toBlockHash32) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF)); if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
// Железное правило формата: bchName -> login + "-NNN"
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) {
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.lineCode = lineCode; this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber; this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); this.prevLineHash32 = prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32);
this.thisLineNumber = thisLineNumber; this.thisLineNumber = thisLineNumber;
this.subType = subType; this.subType = subType;
this.version = VER; this.version = VER;
this.toLogin = toLogin;
this.toBlockchainName = toBlockchainName;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} }
@Override
public ConnectionBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection 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 (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
return this;
}
@Override
public byte[] toBytes() {
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginBytes.length == 0 || loginBytes.length > 255)
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 != 32");
int cap = 4 + 4 + 32 + 4 + 1 + loginBytes.length + 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.put((byte) loginBytes.length);
bb.put(loginBytes);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
return bb.array();
}
private static boolean isValidSubType(short st) { private static boolean isValidSubType(short st) {
int v = st & 0xFFFF; int v = st & 0xFFFF;
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF) return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
@@ -211,76 +172,18 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF); || v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
} }
@Override
public ConnectionBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
// line rule (как было)
if (prevLineNumber == -1) {
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
} else {
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
}
if (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("toBlockchainName is blank");
// гарантируем вычислимый toLogin (иначе target “битый” по стандарту)
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null)
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
return this;
}
@Override
public byte[] toBytes() {
byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (bchBytes.length == 0 || bchBytes.length > 255)
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 != 32");
int cap = 4 + (4 + 32 + 4)
+ 1 + bchBytes.length
+ 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.put((byte) bchBytes.length);
bb.put(bchBytes);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
return bb.array();
}
private static boolean isAllZero32(byte[] b) { private static boolean isAllZero32(byte[] b) {
if (b == null || b.length != 32) return true; if (b == null || b.length != 32) return true;
for (int i = 0; i < 32; i++) if (b[i] != 0) return false; for (byte value : b) if (value != 0) return false;
return true; return true;
} }
/* ====================== BodyHasLine ====================== */
@Override public int lineCode() { return lineCode; } @Override public int lineCode() { return lineCode; }
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
@Override public int lineSeq() { return thisLineNumber; } @Override public int lineSeq() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */ @Override public String toLogin() { return toLogin; }
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
} }
@@ -0,0 +1,194 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* TECH_FORK body (type=0, subType=2, version=1).
*
* Записывается первым новым блоком после точной перепубликации выбранного
* префикса старой цепочки новым blockchain key.
*
* body bytes (BigEndian):
* [32] parentBlockchainKey
* [4] forkPointBlockNumber
* [32] forkPointBlockHash32
* [8] forkPointTimestampMs
* [4] parentTipBlockNumber
* [32] parentTipBlockHash32
* [8] parentTipTimestampMs
* [4] discardedBlocksCount
* [1] reasonCode
* [2] commentUtf8Length
* [N] comment UTF-8 (0..1024 bytes)
*/
public final class ForkBody implements BodyRecord {
public static final short TYPE = 0;
public static final short VER = 1;
public static final short SUBTYPE = MsgSubType.TECH_FORK;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public static final int REASON_ROUTINE_ROTATION = 1;
public static final int REASON_POSSIBLE_COMPROMISE = 2;
public static final int REASON_CONFIRMED_COMPROMISE_ROLLBACK = 3;
public static final int REASON_RECOVERY = 4;
public static final int MAX_COMMENT_UTF8_LEN = 1024;
private static final int FIXED_LEN = 32 + 4 + 32 + 8 + 4 + 32 + 8 + 4 + 1 + 2;
public final short subType;
public final short version;
public final byte[] parentBlockchainKey32;
public final int forkPointBlockNumber;
public final byte[] forkPointBlockHash32;
public final long forkPointTimestampMs;
public final int parentTipBlockNumber;
public final byte[] parentTipBlockHash32;
public final long parentTipTimestampMs;
public final int discardedBlocksCount;
public final int reasonCode;
public final String comment;
public ForkBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)");
}
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody version must be 1");
}
if (bodyBytes.length < FIXED_LEN) {
throw new IllegalArgumentException("ForkBody too short");
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.parentBlockchainKey32 = new byte[32];
bb.get(this.parentBlockchainKey32);
this.forkPointBlockNumber = bb.getInt();
this.forkPointBlockHash32 = new byte[32];
bb.get(this.forkPointBlockHash32);
this.forkPointTimestampMs = bb.getLong();
this.parentTipBlockNumber = bb.getInt();
this.parentTipBlockHash32 = new byte[32];
bb.get(this.parentTipBlockHash32);
this.parentTipTimestampMs = bb.getLong();
this.discardedBlocksCount = bb.getInt();
this.reasonCode = Byte.toUnsignedInt(bb.get());
int commentLen = Short.toUnsignedInt(bb.getShort());
if (commentLen > MAX_COMMENT_UTF8_LEN) {
throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024");
}
if (bb.remaining() != commentLen) {
throw new IllegalArgumentException("ForkBody tail mismatch: remaining=" + bb.remaining() + " commentLen=" + commentLen);
}
byte[] commentBytes = new byte[commentLen];
bb.get(commentBytes);
this.comment = new String(commentBytes, StandardCharsets.UTF_8);
}
public ForkBody(byte[] parentBlockchainKey32,
int forkPointBlockNumber,
byte[] forkPointBlockHash32,
long forkPointTimestampMs,
int parentTipBlockNumber,
byte[] parentTipBlockHash32,
long parentTipTimestampMs,
int discardedBlocksCount,
int reasonCode,
String comment) {
this.subType = SUBTYPE;
this.version = VER;
this.parentBlockchainKey32 = copy32(parentBlockchainKey32, "parentBlockchainKey32");
this.forkPointBlockNumber = forkPointBlockNumber;
this.forkPointBlockHash32 = copy32(forkPointBlockHash32, "forkPointBlockHash32");
this.forkPointTimestampMs = forkPointTimestampMs;
this.parentTipBlockNumber = parentTipBlockNumber;
this.parentTipBlockHash32 = copy32(parentTipBlockHash32, "parentTipBlockHash32");
this.parentTipTimestampMs = parentTipTimestampMs;
this.discardedBlocksCount = discardedBlocksCount;
this.reasonCode = reasonCode;
this.comment = normalizeComment(comment);
}
@Override
public ForkBody check() {
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)");
}
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody version must be 1");
}
require32(parentBlockchainKey32, "parentBlockchainKey32");
require32(forkPointBlockHash32, "forkPointBlockHash32");
require32(parentTipBlockHash32, "parentTipBlockHash32");
if (forkPointBlockNumber < 0) throw new IllegalArgumentException("forkPointBlockNumber < 0");
if (parentTipBlockNumber < forkPointBlockNumber) {
throw new IllegalArgumentException("parentTipBlockNumber < forkPointBlockNumber");
}
if (forkPointTimestampMs < 0) throw new IllegalArgumentException("forkPointTimestampMs < 0");
if (parentTipTimestampMs < 0) throw new IllegalArgumentException("parentTipTimestampMs < 0");
int expectedDiscarded = parentTipBlockNumber - forkPointBlockNumber;
if (discardedBlocksCount != expectedDiscarded) {
throw new IllegalArgumentException("discardedBlocksCount must equal parentTipBlockNumber - forkPointBlockNumber");
}
if (!isReasonSupported(reasonCode)) {
throw new IllegalArgumentException("Unsupported fork reasonCode=" + reasonCode);
}
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
if (commentUtf8.length > MAX_COMMENT_UTF8_LEN) {
throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024");
}
return this;
}
@Override
public byte[] toBytes() {
check();
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
ByteBuffer bb = ByteBuffer.allocate(FIXED_LEN + commentUtf8.length).order(ByteOrder.BIG_ENDIAN);
bb.put(parentBlockchainKey32);
bb.putInt(forkPointBlockNumber);
bb.put(forkPointBlockHash32);
bb.putLong(forkPointTimestampMs);
bb.putInt(parentTipBlockNumber);
bb.put(parentTipBlockHash32);
bb.putLong(parentTipTimestampMs);
bb.putInt(discardedBlocksCount);
bb.put((byte) reasonCode);
bb.putShort((short) commentUtf8.length);
bb.put(commentUtf8);
return bb.array();
}
public static boolean isReasonSupported(int code) {
return code == REASON_ROUTINE_ROTATION
|| code == REASON_POSSIBLE_COMPROMISE
|| code == REASON_CONFIRMED_COMPROMISE_ROLLBACK
|| code == REASON_RECOVERY;
}
private static byte[] copy32(byte[] value, String name) {
require32(value, name);
return Arrays.copyOf(value, 32);
}
private static void require32(byte[] value, String name) {
if (value == null || value.length != 32) {
throw new IllegalArgumentException(name + " must be 32 bytes");
}
}
private static String normalizeComment(String value) {
if (value == null) return "";
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
}
}
@@ -15,12 +15,13 @@ import java.util.Objects;
* 1 = LIKE * 1 = LIKE
* 2 = UNLIKE * 2 = UNLIKE
* *
* bodyBytes (BigEndian), новый формат: * bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8) * [1] toLoginLen (uint8)
* [N] toBlockchainName UTF-8 * [N] toLogin UTF-8
* [4] toBlockGlobalNumber (int32) * [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes) * [32] toBlockHash32 (raw 32 bytes)
* *
* Номер fork/blockchainName в target не хранится.
* ЛИНИИ НЕТ. * ЛИНИИ НЕТ.
*/ */
public final class ReactionBody implements BodyRecord, BodyHasTarget { public final class ReactionBody implements BodyRecord, BodyHasTarget {
@@ -30,10 +31,10 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
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;
public final String toBlockchainName; public final String toLogin;
public final int toBlockGlobalNumber; public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; public final byte[] toBlockHash32;
@@ -49,40 +50,37 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
if (!isSupportedSubType(this.subType)) { if (!isSupportedSubType(this.subType)) {
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF)); throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
} }
// минимум: nameLen[1]+name[1]+global[4]+hash[32]
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short"); if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
int nameLen = Byte.toUnsignedInt(bb.get()); int loginLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short"); if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
byte[] nameBytes = new byte[nameLen]; byte[] loginBytes = new byte[loginLen];
bb.get(nameBytes); bb.get(loginBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt(); this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32]; this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32); bb.get(this.toBlockHash32);
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
} }
public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) { public ReactionBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
this.subType = MsgSubType.REACTION_LIKE; this.subType = MsgSubType.REACTION_LIKE;
this.version = VER; this.version = VER;
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = toBlockchainName; this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} }
@@ -91,37 +89,30 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
public ReactionBody check() { public ReactionBody check() {
if (!isSupportedSubType(subType)) if (!isSupportedSubType(subType))
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF)); throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
if (toLogin == null || toLogin.isBlank())
if (toBlockchainName == null || toBlockchainName.isBlank()) throw new IllegalArgumentException("toLogin is blank");
throw new IllegalArgumentException("toBlockchainName is blank");
if (toBlockGlobalNumber < 0) if (toBlockGlobalNumber < 0)
throw new IllegalArgumentException("toBlockGlobalNumber < 0"); throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 invalid"); throw new IllegalArgumentException("toBlockHash32 invalid");
return this; return this;
} }
@Override @Override
public byte[] toBytes() { public byte[] toBytes() {
byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
if (nameBytes.length == 0 || nameBytes.length > 255) if (loginBytes.length == 0 || loginBytes.length > 255)
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
int cap = 1 + nameBytes.length + 4 + 32; ByteBuffer bb = ByteBuffer.allocate(1 + loginBytes.length + 4 + 32).order(ByteOrder.BIG_ENDIAN);
bb.put((byte) loginBytes.length);
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.put(loginBytes);
bb.put((byte) nameBytes.length);
bb.put(nameBytes);
bb.putInt(toBlockGlobalNumber); bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32); bb.put(toBlockHash32);
return bb.array(); return bb.array();
} }
/* ====================== BodyHasTarget ====================== */ @Override public String toLogin() { return toLogin; }
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -13,11 +13,11 @@ import java.util.Objects;
/** /**
* StatusActionBody — type=5, ver=1. * StatusActionBody — type=5, ver=1.
* *
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение. * Все STATUS_ACTION имеют target на конкретный логический блок и опциональный текст-пояснение.
* *
* Формат bodyBytes (BigEndian): * Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8) * [1] toLoginLen (uint8)
* [N] toBlockchainName UTF-8 * [N] toLogin UTF-8
* [4] toBlockGlobalNumber * [4] toBlockGlobalNumber
* [32] toBlockHash32 * [32] toBlockHash32
* [2] textLenBytes (uint16) * [2] textLenBytes (uint16)
@@ -31,7 +31,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
public final short subType; public final short subType;
public final short version; public final short version;
public final String toBlockchainName; public final String toLogin;
public final int toBlockGlobalNumber; public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; public final byte[] toBlockHash32;
public final String message; public final String message;
@@ -51,33 +51,32 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short"); ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
int nameLen = Byte.toUnsignedInt(bb.get()); int loginLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0"); if (loginLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toLoginLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short"); ensureMin(bb, loginLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
byte[] nameBytes = new byte[nameLen]; byte[] loginBytes = new byte[loginLen];
bb.get(nameBytes); bb.get(loginBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt(); this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32]; this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32); bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text"); this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
ensureNoTail(bb, "StatusActionBody"); ensureNoTail(bb, "StatusActionBody");
} }
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) { public StatusActionBody(short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null"); Objects.requireNonNull(message, "message == null");
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType"); if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.subType = subType; this.subType = subType;
this.version = VER; this.version = VER;
this.toBlockchainName = toBlockchainName; this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message; this.message = message;
@@ -85,16 +84,10 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
@Override @Override
public StatusActionBody check() { public StatusActionBody check() {
if (!isSupportedSubType(subType)) { if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF)); if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("STATUS_ACTION toLogin is blank");
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) { if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (message == null) throw new IllegalArgumentException("message is null"); if (message == null) throw new IllegalArgumentException("message is null");
return this; return this;
} }
@@ -104,15 +97,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) { if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255"); throw new IllegalArgumentException("STATUS_ACTION toLogin utf8 len must be 1..255");
}
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length) ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN); .order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length); bb.put((byte) loginUtf8.length);
bb.put(nameUtf8); bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber); bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32); bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length); bb.putShort((short) msgUtf8.length);
@@ -120,7 +112,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
return bb.array(); return bb.array();
} }
@Override public String toBchName() { return toBlockchainName; } @Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -141,14 +133,11 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
int len = Short.toUnsignedInt(bb.getShort()); int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) return ""; if (len == 0) return "";
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len]; byte[] bytes = new byte[len];
bb.get(bytes); bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder() var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT) .onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT); .onUnmappableCharacter(CodingErrorAction.REPORT);
try { try {
return decoder.decode(ByteBuffer.wrap(bytes)).toString(); return decoder.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException e) { } catch (CharacterCodingException e) {
@@ -33,23 +33,13 @@ import java.util.Objects;
* [2] textLenBytes (uint16) * [2] textLenBytes (uint16)
* [N] text UTF-8 * [N] text UTF-8
* *
* EDIT_POST: * EDIT_POST / REPOST:
* [4] lineCode * [4] lineCode
* [4] prevLineNumber * [4] prevLineNumber
* [32] prevLineHash32 * [32] prevLineHash32
* [4] thisLineNumber * [4] thisLineNumber
* [4] toBlockGlobalNumber (int32) * [1] toLoginLen (uint8)
* [32] toBlockHash32 * [N] toLogin UTF-8
* [2] textLenBytes (uint16)
* [N] text UTF-8
*
* REPOST:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber (int32) * [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 * [32] toBlockHash32
* [2] textLenBytes (uint16) * [2] textLenBytes (uint16)
@@ -72,7 +62,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
public final int thisLineNumber; public final int thisLineNumber;
// target (для EDIT_POST / REPOST) // target (для EDIT_POST / REPOST)
public final String toBlockchainName; // nullable для POST/EDIT_POST public final String toLogin; // nullable для сообщений без target
public final Integer toBlockGlobalNumber; // nullable для POST public final Integer toBlockGlobalNumber; // nullable для POST
public final byte[] toBlockHash32; // nullable для POST public final byte[] toBlockHash32; // nullable для POST
@@ -116,29 +106,19 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.thisLineNumber = bb.getInt(); this.thisLineNumber = bb.getInt();
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
// нужен target ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TEXT target missing");
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target"); int loginLen = Byte.toUnsignedInt(bb.get());
int tgtNum = bb.getInt(); if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
byte[] tgtHash = new byte[32]; ensureMin(bb, loginLen + 4 + 32 + 2, "TEXT target payload too short");
bb.get(tgtHash); byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toBlockchainName = null; this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = tgtNum;
this.toBlockHash32 = tgtHash;
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPOST missing target");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("REPOST toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "REPOST payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt(); this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32]; this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32); bb.get(this.toBlockHash32);
} else { } else {
this.toBlockchainName = null; this.toLogin = null;
this.toBlockGlobalNumber = null; this.toBlockGlobalNumber = null;
this.toBlockHash32 = null; this.toBlockHash32 = null;
} }
@@ -158,7 +138,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
short subType, short subType,
Integer toBlockGlobalNumber, Integer toBlockGlobalNumber,
byte[] toBlockHash32, byte[] toBlockHash32,
String toBlockchainName, String toLogin,
String message) { String message) {
Objects.requireNonNull(message, "message == null"); Objects.requireNonNull(message, "message == null");
@@ -189,27 +169,29 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.thisLineNumber = thisLineNumber; this.thisLineNumber = thisLineNumber;
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = null; this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { } else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toLogin, "toLogin == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = toBlockchainName; this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else { } else {
this.toBlockchainName = null; this.toLogin = null;
this.toBlockGlobalNumber = null; this.toBlockGlobalNumber = null;
this.toBlockHash32 = null; this.toBlockHash32 = null;
} }
@@ -240,13 +222,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid"); throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32) if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid"); throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
if (toBlockchainName != null) if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName"); throw new IllegalArgumentException("EDIT_POST toLogin is blank");
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { } else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
if (message == null || message.isBlank()) if (message == null || message.isBlank())
throw new IllegalArgumentException("REPOST message is blank"); throw new IllegalArgumentException("REPOST message is blank");
if (toBlockchainName == null || toBlockchainName.isBlank()) if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("REPOST toBlockchainName is blank"); throw new IllegalArgumentException("REPOST toLogin is blank");
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid"); throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32) if (toBlockHash32 == null || toBlockHash32.length != 32)
@@ -259,7 +241,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
} else if (message == null) { } else if (message == null) {
throw new IllegalArgumentException("Text message is null"); throw new IllegalArgumentException("Text message is null");
} }
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null) if (toLogin != null || toBlockGlobalNumber != null || toBlockHash32 != null)
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields"); throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
} }
@@ -284,19 +266,14 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF) || st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) { || st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// EDIT_POST
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
} else { } else {
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName"); if (toLogin == null) throw new IllegalArgumentException("target missing toLogin");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); byte[] nameUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) { if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("REPOST toBlockchainName utf8 len must be 1..255"); throw new IllegalArgumentException("target toLogin utf8 len must be 1..255");
} }
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPOST missing toBlockGlobalNumber"); if (toBlockGlobalNumber == null) throw new IllegalArgumentException("target missing toBlockGlobalNumber");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("target toBlockHash32 != 32");
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length; cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
} }
@@ -307,13 +284,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber); bb.putInt(thisLineNumber);
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
bb.putInt(toBlockGlobalNumber); byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
bb.put(toBlockHash32); bb.put((byte) loginUtf8.length);
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { bb.put(loginUtf8);
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber); bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32); bb.put(toBlockHash32);
} }
@@ -331,7 +305,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
@Override public int lineSeq() { return thisLineNumber; } @Override public int lineSeq() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */ /* ====================== BodyHasTarget ===================== */
@Override public String toBchName() { return toBlockchainName; } @Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -11,14 +11,11 @@ import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
/** /**
* TextRatingBody — type=1, ver=1. * TextRatingBody — type=1, ver=1, subType=30.
*
* subType:
* - RATING (30)
* *
* Формат bodyBytes (BigEndian): * Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8) * [1] toLoginLen (uint8)
* [N] toBlockchainName UTF-8 * [N] toLogin UTF-8
* [4] toBlockGlobalNumber * [4] toBlockGlobalNumber
* [32] toBlockHash32 * [32] toBlockHash32
* [2] textLenBytes (uint16) * [2] textLenBytes (uint16)
@@ -32,7 +29,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
public final short subType; public final short subType;
public final short version; public final short version;
public final String toBlockchainName; public final String toLogin;
public final int toBlockGlobalNumber; public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; public final byte[] toBlockHash32;
public final String message; public final String message;
@@ -52,33 +49,32 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short"); ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
int nameLen = Byte.toUnsignedInt(bb.get()); int loginLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0"); if (loginLen <= 0) throw new IllegalArgumentException("RATING toLoginLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short"); ensureMin(bb, loginLen + 4 + 32 + 2, "RATING payload too short");
byte[] nameBytes = new byte[nameLen]; byte[] loginBytes = new byte[loginLen];
bb.get(nameBytes); bb.get(loginBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt(); this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32]; this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32); bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16(bb, "TextRatingBody text"); this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
ensureNoTail(bb, "TextRatingBody"); ensureNoTail(bb, "TextRatingBody");
} }
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) { public TextRatingBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null"); Objects.requireNonNull(message, "message == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (message.isBlank()) throw new IllegalArgumentException("message is blank"); if (message.isBlank()) throw new IllegalArgumentException("message is blank");
this.subType = MsgSubType.TEXT_RATING; this.subType = MsgSubType.TEXT_RATING;
this.version = VER; this.version = VER;
this.toBlockchainName = toBlockchainName; this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message; this.message = message;
@@ -86,16 +82,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
@Override @Override
public TextRatingBody check() { public TextRatingBody check() {
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) { if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF))
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF)); throw new IllegalArgumentException("Bad RATING subType");
} if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("RATING toLogin is blank");
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("RATING toBlockchainName is blank");
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) { if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank"); if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
return this; return this;
} }
@@ -106,15 +97,14 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) { if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255"); throw new IllegalArgumentException("RATING toLogin utf8 len must be 1..255");
}
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length) ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN); .order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length); bb.put((byte) loginUtf8.length);
bb.put(nameUtf8); bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber); bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32); bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length); bb.putShort((short) msgUtf8.length);
@@ -122,7 +112,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
return bb.array(); return bb.array();
} }
@Override public String toBchName() { return toBlockchainName; } @Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -130,14 +120,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
int len = Short.toUnsignedInt(bb.getShort()); int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty"); if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len]; byte[] bytes = new byte[len];
bb.get(bytes); bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder() var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT) .onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT); .onUnmappableCharacter(CodingErrorAction.REPORT);
try { try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
@@ -17,84 +17,56 @@ import java.util.Objects;
* - REPLY (20) * - REPLY (20)
* - EDIT_REPLY (21) * - EDIT_REPLY (21)
* *
* Форматы bodyBytes (BigEndian): * Оба subtype используют одинаковый target-формат:
* * [1] toLoginLen (uint8)
* REPLY: * [N] toLogin UTF-8
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber * [4] toBlockGlobalNumber
* [32] toBlockHash32 * [32] toBlockHash32
* [2] textLenBytes (uint16) * [2] textLenBytes (uint16)
* [M] text UTF-8 * [M] text UTF-8
* *
* EDIT_REPLY: * Для EDIT_REPLY text может быть пустым (логическое удаление).
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [N] text UTF-8
*/ */
public final class TextReplyBody implements BodyRecord, BodyHasTarget { public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 1; public static final short TYPE = 1;
public static final short VER = 1; public static final short VER = 1;
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; // (=1) public final short version;
// target public final String toLogin;
public final String toBlockchainName; // nullable для EDIT_REPLY
public final int toBlockGlobalNumber; public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; // 32 public final byte[] toBlockHash32;
// text
public final String message; public final String message;
public TextReplyBody(short subType, short version, byte[] bodyBytes) { public TextReplyBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null"); Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType; this.subType = subType;
this.version = version; this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF)); throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
} }
int st = this.subType & 0xFFFF; int st = this.subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st); throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
} }
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TextReplyBody too short");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { int loginLen = Byte.toUnsignedInt(bb.get());
// минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2] if (loginLen <= 0) throw new IllegalArgumentException("TextReplyBody toLoginLen is 0");
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short"); ensureMin(bb, loginLen + 4 + 32 + 2, "TextReplyBody payload too short");
int nameLen = Byte.toUnsignedInt(bb.get()); byte[] loginBytes = new byte[loginLen];
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0"); bb.get(loginBytes);
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short"); this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
byte[] nameBytes = new byte[nameLen]; this.toBlockHash32 = new byte[32];
bb.get(nameBytes); bb.get(this.toBlockHash32);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
} else {
// EDIT_REPLY: target без имени
ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");
this.toBlockchainName = null;
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
}
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)); this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
ensureNoTail(bb, "TextReplyBody"); ensureNoTail(bb, "TextReplyBody");
@@ -103,11 +75,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public TextReplyBody(short subType, public TextReplyBody(short subType,
int toBlockGlobalNumber, int toBlockGlobalNumber,
byte[] toBlockHash32, byte[] toBlockHash32,
String toBlockchainName, String toLogin,
String message) { String message) {
Objects.requireNonNull(message, "message == null"); Objects.requireNonNull(message, "message == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(toLogin, "toLogin == null");
int st = subType & 0xFFFF; int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
@@ -116,25 +88,15 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) { if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
throw new IllegalArgumentException("message is blank"); throw new IllegalArgumentException("message is blank");
} }
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
this.toBlockchainName = toBlockchainName;
} else {
// EDIT_REPLY: имя не хранить
this.toBlockchainName = null;
}
this.subType = subType; this.subType = subType;
this.version = VER; this.version = VER;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message; this.message = message;
} }
@@ -143,23 +105,14 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
int st = subType & 0xFFFF; int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st); throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 invalid");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (message == null || message.isBlank()) if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank");
throw new IllegalArgumentException("Text message is blank"); } else if (message == null) {
if (toBlockchainName == null || toBlockchainName.isBlank()) throw new IllegalArgumentException("EDIT_REPLY message is null");
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
} else {
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
if (toBlockchainName != null)
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
} }
return this; return this;
} }
@@ -167,47 +120,27 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public byte[] toBytes() { public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF; int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) { if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
throw new IllegalArgumentException("Text payload is empty"); throw new IllegalArgumentException("Text payload is empty");
} }
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName"); if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("TextReplyBody toLogin utf8 len must be 1..255");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
if (nameUtf8.length == 0 || nameUtf8.length > 255) .order(ByteOrder.BIG_ENDIAN);
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255"); bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
// EDIT_REPLY
int cap = (4 + 32) + 2 + msgUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(toBlockGlobalNumber); bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32); bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length); bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8); bb.put(msgUtf8);
return bb.array(); return bb.array();
} }
/* ====================== BodyHasTarget ====================== */ @Override public String toLogin() { return toLogin; }
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -215,8 +148,6 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
} }
/* ====================== helpers ====================== */
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) { private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
int len = Short.toUnsignedInt(bb.getShort()); int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) { if (len == 0) {
@@ -224,14 +155,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
throw new IllegalArgumentException(fieldName + " is empty"); throw new IllegalArgumentException(fieldName + " is empty");
} }
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len]; byte[] bytes = new byte[len];
bb.get(bytes); bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder() var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT) .onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT); .onUnmappableCharacter(CodingErrorAction.REPORT);
try { try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
@@ -0,0 +1,85 @@
package blockchain;
import blockchain.body.ForkBody;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
final class ForkBodyTest {
@Test
void roundTripThroughBodyRecordParser() {
byte[] parentKey = filled(32, 0x11);
byte[] forkHash = filled(32, 0x22);
byte[] tipHash = filled(32, 0x33);
ForkBody source = new ForkBody(
parentKey,
120,
forkHash,
1_700_000_000_000L,
137,
tipHash,
1_700_100_000_000L,
17,
ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK,
"Удаляю неизвестные записи"
).check();
ForkBody parsed = assertInstanceOf(
ForkBody.class,
BodyRecordParser.parse(ForkBody.TYPE, ForkBody.SUBTYPE, ForkBody.VER, source.toBytes())
);
assertArrayEquals(parentKey, parsed.parentBlockchainKey32);
assertEquals(120, parsed.forkPointBlockNumber);
assertArrayEquals(forkHash, parsed.forkPointBlockHash32);
assertEquals(137, parsed.parentTipBlockNumber);
assertArrayEquals(tipHash, parsed.parentTipBlockHash32);
assertEquals(17, parsed.discardedBlocksCount);
assertEquals(ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK, parsed.reasonCode);
assertEquals("Удаляю неизвестные записи", parsed.comment);
}
@Test
void routineRotationMayDiscardNothing() {
ForkBody body = new ForkBody(
filled(32, 1),
10,
filled(32, 2),
1000,
10,
filled(32, 2),
1000,
0,
ForkBody.REASON_ROUTINE_ROTATION,
""
);
assertDoesNotThrow(body::check);
}
@Test
void rejectedWhenDiscardCountDoesNotMatchForkPointAndTip() {
ForkBody body = new ForkBody(
filled(32, 1),
10,
filled(32, 2),
1000,
12,
filled(32, 3),
2000,
99,
ForkBody.REASON_POSSIBLE_COMPROMISE,
""
);
assertThrows(IllegalArgumentException.class, body::check);
}
private static byte[] filled(int size, int value) {
byte[] out = new byte[size];
Arrays.fill(out, (byte) value);
return out;
}
}
@@ -0,0 +1,73 @@
package blockchain;
import blockchain.body.ConnectionBody;
import blockchain.body.ReactionBody;
import blockchain.body.StatusActionBody;
import blockchain.body.TextLineBody;
import blockchain.body.TextReplyBody;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
final class TargetLoginFormatTest {
private static final byte[] HASH = hash(7);
@Test
void reactionRoundTripStoresLoginNotBlockchainName() {
ReactionBody source = new ReactionBody("alice", 42, HASH);
ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check();
assertEquals("alice", parsed.toLogin());
assertEquals(42, parsed.toBlockGlobalNumber());
assertArrayEquals(HASH, parsed.toBlockHashBytes());
assertFalse(new String(source.toBytes()).contains("alice-001"));
}
@Test
void replyAndEditReplyUseSameLoginTargetShape() {
TextReplyBody reply = new TextReplyBody(MsgSubType.TEXT_REPLY, 11, HASH, "alice", "reply");
TextReplyBody parsedReply = new TextReplyBody(MsgSubType.TEXT_REPLY, TextReplyBody.VER, reply.toBytes()).check();
assertEquals("alice", parsedReply.toLogin());
TextReplyBody edit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, 11, HASH, "alice", "");
TextReplyBody parsedEdit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, TextReplyBody.VER, edit.toBytes()).check();
assertEquals("alice", parsedEdit.toLogin());
assertEquals("", parsedEdit.message);
}
@Test
void editPostAndConnectionCarryLoginTarget() {
TextLineBody editPost = new TextLineBody(
0, -1, new byte[32], -1,
MsgSubType.TEXT_EDIT_POST,
5, HASH, "alice", "edited"
);
TextLineBody parsedEditPost = new TextLineBody(MsgSubType.TEXT_EDIT_POST, TextLineBody.VER, editPost.toBytes()).check();
assertEquals("alice", parsedEditPost.toLogin());
ConnectionBody connection = new ConnectionBody(
0, -1, new byte[32], -1,
MsgSubType.CONNECTION_FOLLOW,
"alice", 0, HASH
);
ConnectionBody parsedConnection = new ConnectionBody(MsgSubType.CONNECTION_FOLLOW, ConnectionBody.VER, connection.toBytes()).check();
assertEquals("alice", parsedConnection.toLogin());
}
@Test
void statusActionCarriesLoginTarget() {
StatusActionBody source = new StatusActionBody(MsgSubType.STATUS_STARTED, "alice", 77, HASH, "");
StatusActionBody parsed = new StatusActionBody(MsgSubType.STATUS_STARTED, StatusActionBody.VER, source.toBytes()).check();
assertEquals("alice", parsed.toLogin());
assertEquals(77, parsed.toBlockGlobalNumber());
}
private static byte[] hash(int seed) {
byte[] out = new byte[32];
Arrays.fill(out, (byte) seed);
return out;
}
}
@@ -42,6 +42,8 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_23 = 23; public static final int SCHEMA_VERSION_23 = 23;
public static final int SCHEMA_VERSION_24 = 24; public static final int SCHEMA_VERSION_24 = 24;
public static final int SCHEMA_VERSION_25 = 25; public static final int SCHEMA_VERSION_25 = 25;
public static final int SCHEMA_VERSION_26 = 26;
public static final int SCHEMA_VERSION_27 = 27;
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
@@ -67,6 +69,8 @@ public final class DatabaseInitializer {
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql"; public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql"; public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql"; public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
public static final String POSTGRES_MIGRATION_V26_RESOURCE = "postgres/migration_v26.sql";
public static final String POSTGRES_MIGRATION_V27_RESOURCE = "postgres/migration_v27.sql";
private DatabaseInitializer() {} private DatabaseInitializer() {}
@@ -236,6 +240,14 @@ public final class DatabaseInitializer {
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE); runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
currentVersion = SCHEMA_VERSION_25; currentVersion = SCHEMA_VERSION_25;
} }
if (currentVersion < SCHEMA_VERSION_26) {
runSqlScript(conn, POSTGRES_MIGRATION_V26_RESOURCE);
currentVersion = SCHEMA_VERSION_26;
}
if (currentVersion < SCHEMA_VERSION_27) {
runSqlScript(conn, POSTGRES_MIGRATION_V27_RESOURCE);
currentVersion = SCHEMA_VERSION_27;
}
} }
} }
@@ -127,6 +127,100 @@ public final class BlockchainResyncCleanupDAO {
} }
} }
/**
* После смены fork обновляет только runtime-кэш физического blockchainName у логических target-ссылок.
* Подписанная идентичность target уже задаётся login + blockNumber + blockHash; поэтому смена cache value
* не меняет смысл ссылки и не требует переподписывать чужие блоки.
*/
public void refreshLogicalTargetBlockchainCache(String login, String oldBlockchainName, String newBlockchainName) throws SQLException {
if (login == null || login.isBlank() || newBlockchainName == null || newBlockchainName.isBlank()) {
throw new IllegalArgumentException("login/newBlockchainName are required");
}
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
updateTargetCacheByLogin(c, "blocks", login, newBlockchainName);
updateTargetCacheByLogin(c, "connections_state", login, newBlockchainName);
updateTargetCacheByLogin(c, "reactions_state", login, newBlockchainName);
updateTargetCacheByLogin(c, "message_stats", login, newBlockchainName);
if (oldBlockchainName != null && !oldBlockchainName.isBlank()) {
try (PreparedStatement ps = c.prepareStatement("UPDATE message_views_state SET to_bch_name=? WHERE to_bch_name=?")) {
ps.setString(1, newBlockchainName);
ps.setString(2, oldBlockchainName);
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("UPDATE channel_read_state SET owner_bch_name=? WHERE owner_bch_name=?")) {
ps.setString(1, newBlockchainName);
ps.setString(2, oldBlockchainName);
ps.executeUpdate();
}
}
rebuildInboundMessageCounters(c, login, newBlockchainName);
rebuildStatsState(c);
c.commit();
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to refresh logical target blockchain cache for login=" + login, e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
/**
* После replay candidate-цепочки её собственные message_stats уже существуют, но входящие реакции/ответы
* других пользователей были созданы раньше и не проходили триггеры повторно. Поэтому пересчитываем
* агрегаты из исходных таблиц после перепривязки runtime to_bch_name cache.
*/
private void rebuildInboundMessageCounters(Connection c, String login, String newBlockchainName) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
UPDATE message_stats ms
SET likes_count = (
SELECT COUNT(*)::INTEGER
FROM reactions_state rs
WHERE rs.reaction_type = ?
AND rs.last_sub_type = ?
AND LOWER(rs.to_login) = LOWER(ms.to_login)
AND rs.to_bch_name = ms.to_bch_name
AND rs.to_block_number = ms.to_block_number
AND rs.to_block_hash = ms.to_block_hash
),
replies_count = (
SELECT COUNT(*)::INTEGER
FROM blocks b
WHERE b.msg_type = 1
AND b.msg_sub_type = ?
AND LOWER(b.to_login) = LOWER(ms.to_login)
AND b.to_bch_name = ms.to_bch_name
AND b.to_block_number = ms.to_block_number
AND b.to_block_hash = ms.to_block_hash
)
WHERE LOWER(ms.to_login) = LOWER(?)
AND ms.to_bch_name = ?
""")) {
ps.setInt(1, DatabaseInitializer.REACTION_LIKE);
ps.setInt(2, DatabaseInitializer.REACTION_LIKE);
ps.setInt(3, DatabaseInitializer.TEXT_REPLY);
ps.setString(4, login);
ps.setString(5, newBlockchainName);
ps.executeUpdate();
}
}
private void updateTargetCacheByLogin(Connection c, String table, String login, String newBlockchainName) throws SQLException {
String sql = "UPDATE " + table + " SET to_bch_name=? WHERE LOWER(to_login)=LOWER(?) AND to_bch_name IS DISTINCT FROM ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, newBlockchainName);
ps.setString(2, login);
ps.setString(3, newBlockchainName);
ps.executeUpdate();
}
}
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException { private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
String sql = """ String sql = """
SELECT login SELECT login
@@ -0,0 +1,236 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/** Хранилище candidate-блоков будущего fork во время ротации ключей. */
public final class KeyRotationCandidateBlocksDAO {
private static volatile KeyRotationCandidateBlocksDAO instance;
private final DbController db = DbController.getInstance();
private KeyRotationCandidateBlocksDAO() { }
public static KeyRotationCandidateBlocksDAO getInstance() {
if (instance == null) {
synchronized (KeyRotationCandidateBlocksDAO.class) {
if (instance == null) instance = new KeyRotationCandidateBlocksDAO();
}
}
return instance;
}
/**
* Идемпотентно сохраняет один candidate-блок.
* Повтор того же blockNumber допустим только при полном совпадении hash и DataItem id.
*/
public KeyRotationCandidateBlockEntry insertOrGet(Connection c, KeyRotationCandidateBlockEntry entry) throws SQLException {
KeyRotationCandidateBlockEntry existing = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber());
if (existing != null) {
if (Arrays.equals(existing.getBlockHash(), entry.getBlockHash())
&& Arrays.equals(existing.getDataItemId(), entry.getDataItemId())) {
return existing;
}
throw new SQLException("Candidate block conflict at number=" + entry.getBlockNumber());
}
String sql = """
INSERT INTO key_rotation_candidate_blocks (
rotation_session_id, login, candidate_blockchain_name,
block_number, block_hash, data_item_id, block_bytes,
arweave_publish_pending, arweave_published_at_ms, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, NULL, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
int i = 1;
ps.setLong(i++, entry.getRotationSessionId());
ps.setString(i++, entry.getLogin());
ps.setString(i++, entry.getCandidateBlockchainName());
ps.setInt(i++, entry.getBlockNumber());
ps.setBytes(i++, entry.getBlockHash());
ps.setBytes(i++, entry.getDataItemId());
ps.setBytes(i++, entry.getBlockBytes());
ps.setLong(i++, entry.getCreatedAtMs());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) throw new SQLException("candidate block insert returned no id");
entry.setId(keys.getLong(1));
}
} catch (SQLException insertFailure) {
// Конкурирующий повтор из другой сессии мог успеть вставиться между SELECT и INSERT.
KeyRotationCandidateBlockEntry raced = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber());
if (raced != null
&& Arrays.equals(raced.getBlockHash(), entry.getBlockHash())
&& Arrays.equals(raced.getDataItemId(), entry.getDataItemId())) {
return raced;
}
throw insertFailure;
}
entry.setArweavePublishPending(true);
return entry;
}
public KeyRotationCandidateBlockEntry getByNumber(Connection c, long sessionId, int blockNumber) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT * FROM key_rotation_candidate_blocks
WHERE rotation_session_id = ? AND block_number = ?
""")) {
ps.setLong(1, sessionId);
ps.setInt(2, blockNumber);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? map(rs) : null;
}
}
}
public int countStored(Connection c, long sessionId) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT COUNT(*) FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?
""")) {
ps.setLong(1, sessionId);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getInt(1);
}
}
}
public int countPublished(Connection c, long sessionId) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT COUNT(*) FROM key_rotation_candidate_blocks
WHERE rotation_session_id = ? AND arweave_publish_pending = FALSE
""")) {
ps.setLong(1, sessionId);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getInt(1);
}
}
}
/** Все candidate-блоки одной ротации в строгом порядке block_number. */
public List<KeyRotationCandidateBlockEntry> listBySession(Connection c, long sessionId) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT * FROM key_rotation_candidate_blocks
WHERE rotation_session_id = ?
ORDER BY block_number
""")) {
ps.setLong(1, sessionId);
List<KeyRotationCandidateBlockEntry> out = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(map(rs));
}
return out;
}
}
/** Candidate-блоки имеют приоритет перед обычной очередью publisher-а. */
public List<KeyRotationCandidateBlockEntry> listPendingArweave(int limit) throws SQLException {
if (limit <= 0) return List.of();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
SELECT * FROM key_rotation_candidate_blocks
WHERE arweave_publish_pending = TRUE
ORDER BY id
LIMIT ?
""")) {
ps.setInt(1, limit);
List<KeyRotationCandidateBlockEntry> out = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(map(rs));
}
return out;
}
}
/**
* Помечает DataItem опубликованными и синхронизирует progress_current ротации
* с реальным количеством DataItem, подтверждённых publisher-ом.
*/
public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
if (dataItemIds == null || dataItemIds.isEmpty()) return;
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
java.util.Set<Long> sessions = new java.util.HashSet<>();
try (PreparedStatement find = c.prepareStatement("""
SELECT rotation_session_id
FROM key_rotation_candidate_blocks
WHERE data_item_id = ?
""")) {
for (byte[] id : dataItemIds) {
find.setBytes(1, id);
try (ResultSet rs = find.executeQuery()) {
if (rs.next()) sessions.add(rs.getLong(1));
}
}
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_candidate_blocks
SET arweave_publish_pending = FALSE,
arweave_published_at_ms = COALESCE(arweave_published_at_ms, ?)
WHERE data_item_id = ?
""")) {
for (byte[] id : dataItemIds) {
ps.setLong(1, publishedAtMs);
ps.setBytes(2, id);
ps.addBatch();
}
ps.executeBatch();
}
try (PreparedStatement progress = c.prepareStatement("""
UPDATE key_rotation_sessions s
SET progress_current = LEAST(s.progress_total, (
SELECT COUNT(*)::INTEGER
FROM key_rotation_candidate_blocks b
WHERE b.rotation_session_id = s.id
AND b.arweave_publish_pending = FALSE
)),
updated_at_ms = ?
WHERE s.id = ? AND s.status = 'COPYING_CHAIN'
""")) {
for (Long sessionId : sessions) {
progress.setLong(1, publishedAtMs);
progress.setLong(2, sessionId);
progress.addBatch();
}
progress.executeBatch();
}
c.commit();
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to mark candidate DataItems published", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
private static KeyRotationCandidateBlockEntry map(ResultSet rs) throws SQLException {
KeyRotationCandidateBlockEntry e = new KeyRotationCandidateBlockEntry();
e.setId(rs.getLong("id"));
e.setRotationSessionId(rs.getLong("rotation_session_id"));
e.setLogin(rs.getString("login"));
e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name"));
e.setBlockNumber(rs.getInt("block_number"));
e.setBlockHash(rs.getBytes("block_hash"));
e.setDataItemId(rs.getBytes("data_item_id"));
e.setBlockBytes(rs.getBytes("block_bytes"));
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending"));
e.setArweavePublishedAtMs((Long) rs.getObject("arweave_published_at_ms"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
return e;
}
}
@@ -0,0 +1,612 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
/**
* DAO для серверной машины смены ключей.
*
* Все методы изменения статуса синхронно обновляют и key_rotation_sessions,
* и solana_user_pda_current.rotation_status / rotation_session_id.
*/
public final class KeyRotationSessionsDAO {
private static volatile KeyRotationSessionsDAO instance;
private final DbController db = DbController.getInstance();
private KeyRotationSessionsDAO() { }
public static KeyRotationSessionsDAO getInstance() {
if (instance == null) {
synchronized (KeyRotationSessionsDAO.class) {
if (instance == null) instance = new KeyRotationSessionsDAO();
}
}
return instance;
}
/**
* Атомарно создаёт первую серверную запись ротации сразу в COPYING_CHAIN.
* PREPARING в БД отсутствует: до этого момента всё является локальным UI-состоянием.
*/
public KeyRotationSessionEntry createCopyingSession(KeyRotationSessionEntry entry) throws SQLException {
validateNewSession(entry);
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
KeyRotationSessionEntry created = createCopyingSession(c, entry);
c.commit();
return created;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to create key rotation session", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
public KeyRotationSessionEntry createCopyingSession(Connection c, KeyRotationSessionEntry entry) throws SQLException {
validateNewSession(entry);
String currentStatus;
try (PreparedStatement ps = c.prepareStatement("""
SELECT rotation_status
FROM solana_user_pda_current
WHERE login = ?
FOR UPDATE
""")) {
ps.setString(1, entry.getLogin());
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) throw new SQLException("Unknown login for key rotation: " + entry.getLogin());
currentStatus = rs.getString(1);
}
}
if (!KeyRotationStatus.NONE.name().equals(currentStatus)) {
throw new SQLException("Key rotation already active for login=" + entry.getLogin() + ", status=" + currentStatus);
}
long now = entry.getCreatedAtMs() > 0 ? entry.getCreatedAtMs() : System.currentTimeMillis();
entry.setCreatedAtMs(now);
entry.setUpdatedAtMs(now);
entry.setStatus(KeyRotationStatus.COPYING_CHAIN);
if (entry.getComment() == null) entry.setComment("");
if (entry.getWalletMigrationStatus() == null) entry.setWalletMigrationStatus("PENDING");
if (entry.getMessageMigrationStatus() == null) entry.setMessageMigrationStatus("PENDING");
String sql = """
INSERT INTO key_rotation_sessions (
login, status,
source_blockchain_name, candidate_blockchain_name,
old_root_key, old_blockchain_key, old_client_key,
new_root_key, new_blockchain_key, new_client_key,
fork_from_block, fork_from_hash,
source_tip_block, source_tip_hash,
reason_code, comment,
progress_current, progress_total,
pda_rotation_signature,
wallet_migration_status, message_migration_status,
last_error, last_error_at_ms, retry_count,
created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
""";
long id;
try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
int i = 1;
ps.setString(i++, entry.getLogin());
ps.setString(i++, entry.getStatus().name());
ps.setString(i++, entry.getSourceBlockchainName());
ps.setString(i++, entry.getCandidateBlockchainName());
ps.setString(i++, entry.getOldRootKey());
ps.setString(i++, entry.getOldBlockchainKey());
ps.setString(i++, entry.getOldClientKey());
ps.setString(i++, entry.getNewRootKey());
ps.setString(i++, entry.getNewBlockchainKey());
ps.setString(i++, entry.getNewClientKey());
ps.setInt(i++, entry.getForkFromBlock());
ps.setBytes(i++, entry.getForkFromHash());
ps.setInt(i++, entry.getSourceTipBlock());
ps.setBytes(i++, entry.getSourceTipHash());
ps.setShort(i++, entry.getReasonCode());
ps.setString(i++, entry.getComment());
ps.setInt(i++, entry.getProgressCurrent());
ps.setInt(i++, entry.getProgressTotal());
ps.setString(i++, entry.getPdaRotationSignature());
ps.setString(i++, entry.getWalletMigrationStatus());
ps.setString(i++, entry.getMessageMigrationStatus());
ps.setString(i++, entry.getLastError());
if (entry.getLastErrorAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getLastErrorAtMs());
ps.setInt(i++, entry.getRetryCount());
ps.setLong(i++, entry.getCreatedAtMs());
ps.setLong(i++, entry.getUpdatedAtMs());
if (entry.getCompletedAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getCompletedAtMs());
if (entry.getAbortedAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getAbortedAtMs());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) throw new SQLException("key_rotation_sessions insert returned no id");
id = keys.getLong(1);
}
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = ?, rotation_session_id = ?
WHERE login = ? AND rotation_status = 'NONE'
""")) {
ps.setString(1, KeyRotationStatus.COPYING_CHAIN.name());
ps.setLong(2, id);
ps.setString(3, entry.getLogin());
if (ps.executeUpdate() != 1) {
throw new SQLException("Failed to attach key rotation session to login=" + entry.getLogin());
}
}
entry.setId(id);
return entry;
}
public KeyRotationSessionEntry getById(long id) throws SQLException {
try (Connection c = db.getConnection()) {
return getById(c, id);
}
}
public KeyRotationSessionEntry getById(Connection c, long id) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public KeyRotationSessionEntry getActiveByLogin(String login) throws SQLException {
try (Connection c = db.getConnection()) {
return getActiveByLogin(c, login);
}
}
public KeyRotationSessionEntry getActiveByLogin(Connection c, String login) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(selectBase() + """
WHERE login = ?
AND status NOT IN ('COMPLETE', 'ABORTED')
ORDER BY id DESC
LIMIT 1
""")) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
/** Возвращает несколько сессий одного состояния для фоновых workers. */
public java.util.List<KeyRotationSessionEntry> listByStatus(KeyRotationStatus status, int limit) throws SQLException {
if (status == null || status == KeyRotationStatus.NONE) return java.util.List.of();
int safeLimit = Math.max(1, Math.min(limit, 100));
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(selectBase() + """
WHERE status = ?
ORDER BY updated_at_ms, id
LIMIT ?
""")) {
ps.setString(1, status.name());
ps.setInt(2, safeLimit);
java.util.List<KeyRotationSessionEntry> out = new java.util.ArrayList<>();
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
return out;
}
}
/**
* Атомарный переход state machine. Смена статуса выполняется только из expected.
*/
public KeyRotationSessionEntry transition(long id,
KeyRotationStatus expected,
KeyRotationStatus next) throws SQLException {
if (expected == null || next == null || !expected.canTransitionTo(next)) {
throw new IllegalArgumentException("Forbidden key rotation transition: " + expected + " -> " + next);
}
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
if (current == null) throw new SQLException("Unknown key rotation session id=" + id);
if (current.getStatus() != expected) {
throw new SQLException("Key rotation status mismatch: expected=" + expected + ", actual=" + current.getStatus());
}
long now = System.currentTimeMillis();
Long completed = current.getCompletedAtMs();
if (next == KeyRotationStatus.COMPLETE) completed = now;
Long aborted = current.getAbortedAtMs();
if (next == KeyRotationStatus.ABORTED) aborted = now;
try (PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET status = ?, updated_at_ms = ?, completed_at_ms = ?, aborted_at_ms = ?
WHERE id = ? AND status = ?
""")) {
ps.setString(1, next.name());
ps.setLong(2, now);
if (completed == null) ps.setNull(3, java.sql.Types.BIGINT); else ps.setLong(3, completed);
if (aborted == null) ps.setNull(4, java.sql.Types.BIGINT); else ps.setLong(4, aborted);
ps.setLong(5, id);
ps.setString(6, expected.name());
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent key rotation transition for id=" + id);
}
KeyRotationStatus userStatus = next.userStatus();
try (PreparedStatement ps = c.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = ?, rotation_session_id = ?
WHERE login = ? AND rotation_session_id = ?
""")) {
ps.setString(1, userStatus.name());
if (userStatus == KeyRotationStatus.NONE) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, id);
ps.setString(3, current.getLogin());
ps.setLong(4, id);
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin());
}
// Candidate-копии нужны только пока ротация активна. В Arweave они уже опубликованы,
// а в PostgreSQL после COMPLETE/ABORTED временные строки больше не нужны.
if (next.isTerminalSessionStatus()) {
try (PreparedStatement ps = c.prepareStatement(
"DELETE FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?")) {
ps.setLong(1, id);
ps.executeUpdate();
}
}
c.commit();
current.setStatus(next);
current.setUpdatedAtMs(now);
current.setCompletedAtMs(completed);
current.setAbortedAtMs(aborted);
return current;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed key rotation transition", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
public void updateProgress(long id, int current, int total) throws SQLException {
if (current < 0 || total < 0 || current > total) {
throw new IllegalArgumentException("Invalid rotation progress: " + current + "/" + total);
}
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET progress_current = ?, progress_total = ?, updated_at_ms = ?
WHERE id = ? AND status = 'COPYING_CHAIN'
""")) {
ps.setInt(1, current);
ps.setInt(2, total);
ps.setLong(3, System.currentTimeMillis());
ps.setLong(4, id);
if (ps.executeUpdate() != 1) throw new SQLException("Rotation progress can only be updated in COPYING_CHAIN, id=" + id);
}
}
/**
* Атомарно фиксирует tx signature и переводит CHAIN_READY -> ROTATING_PDA.
* Это точка, после которой Abort запрещён: отправленная Solana-транзакция может подтвердиться позже.
*/
public KeyRotationSessionEntry beginPdaRotation(long id, String signature) throws SQLException {
if (signature == null || signature.isBlank()) throw new IllegalArgumentException("signature is empty");
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
if (current == null) throw new SQLException("Unknown key rotation session id=" + id);
if (current.getStatus() != KeyRotationStatus.CHAIN_READY) {
throw new SQLException("PDA rotation requires CHAIN_READY, actual=" + current.getStatus());
}
long now = System.currentTimeMillis();
try (PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET status = 'ROTATING_PDA', pda_rotation_signature = ?, updated_at_ms = ?,
last_error = NULL, last_error_at_ms = NULL
WHERE id = ? AND status = 'CHAIN_READY'
""")) {
ps.setString(1, signature);
ps.setLong(2, now);
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation start for id=" + id);
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = 'ROTATING_PDA', rotation_session_id = ?
WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'CHAIN_READY'
""")) {
ps.setLong(1, id);
ps.setString(2, current.getLogin());
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin());
}
c.commit();
current.setStatus(KeyRotationStatus.ROTATING_PDA);
current.setPdaRotationSignature(signature);
current.setUpdatedAtMs(now);
current.setLastError(null);
current.setLastErrorAtMs(null);
return current;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to begin PDA rotation", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
/**
* Если Solana sync уже записал в current-PDA весь ожидаемый новый набор ключей,
* атомарно переводит ROTATING_PDA -> PDA_ROTATED. Иначе возвращает null.
*/
public KeyRotationSessionEntry tryMarkPdaRotatedFromCurrentState(long id) throws SQLException {
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
if (current == null || current.getStatus() != KeyRotationStatus.ROTATING_PDA) {
c.rollback();
return current != null && current.getStatus() == KeyRotationStatus.PDA_ROTATED ? current : null;
}
String root;
String blockchain;
String client;
String blockchainName;
try (PreparedStatement ps = c.prepareStatement("""
SELECT root_key, blockchain_key, client_key, blockchain_name
FROM solana_user_pda_current
WHERE login = ?
FOR UPDATE
""")) {
ps.setString(1, current.getLogin());
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) { c.rollback(); return null; }
root = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("root_key"));
blockchain = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key"));
client = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("client_key"));
blockchainName = rs.getString("blockchain_name");
}
}
if (!current.getNewRootKey().equals(root)
|| !current.getNewBlockchainKey().equals(blockchain)
|| !current.getNewClientKey().equals(client)
|| !current.getCandidateBlockchainName().equals(blockchainName)) {
c.rollback();
return null;
}
long now = System.currentTimeMillis();
try (PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET status = 'PDA_ROTATED', updated_at_ms = ?, last_error = NULL, last_error_at_ms = NULL
WHERE id = ? AND status = 'ROTATING_PDA'
""")) {
ps.setLong(1, now);
ps.setLong(2, id);
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation confirm for id=" + id);
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = 'PDA_ROTATED', rotation_session_id = ?
WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'ROTATING_PDA'
""")) {
ps.setLong(1, id);
ps.setString(2, current.getLogin());
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to current PDA login=" + current.getLogin());
}
c.commit();
current.setStatus(KeyRotationStatus.PDA_ROTATED);
current.setUpdatedAtMs(now);
current.setLastError(null);
current.setLastErrorAtMs(null);
return current;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to confirm rotated PDA", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
public void setPdaRotationSignature(long id, String signature) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET pda_rotation_signature = ?, updated_at_ms = ?
WHERE id = ? AND status = 'ROTATING_PDA'
""")) {
ps.setString(1, signature);
ps.setLong(2, System.currentTimeMillis());
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("PDA signature requires ROTATING_PDA, id=" + id);
}
}
/**
* Фиксирует результат необязательного этапа wallet/DM.
* Имя колонки выбирается только из закрытого списка — внешние значения в SQL не подставляются.
*/
public void setMigrationSubStatus(long id, String column, String value) throws SQLException {
String safeColumn = switch (String.valueOf(column)) {
case "wallet_migration_status" -> "wallet_migration_status";
case "message_migration_status" -> "message_migration_status";
default -> throw new IllegalArgumentException("Unsupported migration status column: " + column);
};
String safeValue = switch (String.valueOf(value)) {
case "PENDING", "COMPLETE", "SKIPPED", "NOT_IMPLEMENTED" -> String.valueOf(value);
default -> throw new IllegalArgumentException("Unsupported migration status value: " + value);
};
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(
"UPDATE key_rotation_sessions SET " + safeColumn + " = ?, updated_at_ms = ? WHERE id = ?")) {
ps.setString(1, safeValue);
ps.setLong(2, System.currentTimeMillis());
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("Unknown key rotation session id=" + id);
}
}
public void recordError(long id, String error) throws SQLException {
long now = System.currentTimeMillis();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET last_error = ?, last_error_at_ms = ?, retry_count = retry_count + 1, updated_at_ms = ?
WHERE id = ? AND status NOT IN ('COMPLETE', 'ABORTED')
""")) {
ps.setString(1, error);
ps.setLong(2, now);
ps.setLong(3, now);
ps.setLong(4, id);
if (ps.executeUpdate() != 1) throw new SQLException("Cannot record error for inactive rotation id=" + id);
}
}
public void clearError(long id) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET last_error = NULL, last_error_at_ms = NULL, updated_at_ms = ?
WHERE id = ?
""")) {
ps.setLong(1, System.currentTimeMillis());
ps.setLong(2, id);
ps.executeUpdate();
}
}
private KeyRotationSessionEntry getByIdForUpdate(Connection c, long id) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ? FOR UPDATE")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
private static String selectBase() {
return """
SELECT
id, login, status,
source_blockchain_name, candidate_blockchain_name,
old_root_key, old_blockchain_key, old_client_key,
new_root_key, new_blockchain_key, new_client_key,
fork_from_block, fork_from_hash,
source_tip_block, source_tip_hash,
reason_code, comment,
progress_current, progress_total,
pda_rotation_signature,
wallet_migration_status, message_migration_status,
last_error, last_error_at_ms, retry_count,
created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms
FROM key_rotation_sessions
""";
}
private static KeyRotationSessionEntry mapRow(ResultSet rs) throws SQLException {
KeyRotationSessionEntry e = new KeyRotationSessionEntry();
e.setId(rs.getLong("id"));
e.setLogin(rs.getString("login"));
e.setStatus(KeyRotationStatus.valueOf(rs.getString("status")));
e.setSourceBlockchainName(rs.getString("source_blockchain_name"));
e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name"));
e.setOldRootKey(rs.getString("old_root_key"));
e.setOldBlockchainKey(rs.getString("old_blockchain_key"));
e.setOldClientKey(rs.getString("old_client_key"));
e.setNewRootKey(rs.getString("new_root_key"));
e.setNewBlockchainKey(rs.getString("new_blockchain_key"));
e.setNewClientKey(rs.getString("new_client_key"));
e.setForkFromBlock(rs.getInt("fork_from_block"));
e.setForkFromHash(rs.getBytes("fork_from_hash"));
e.setSourceTipBlock(rs.getInt("source_tip_block"));
e.setSourceTipHash(rs.getBytes("source_tip_hash"));
e.setReasonCode(rs.getShort("reason_code"));
e.setComment(rs.getString("comment"));
e.setProgressCurrent(rs.getInt("progress_current"));
e.setProgressTotal(rs.getInt("progress_total"));
e.setPdaRotationSignature(rs.getString("pda_rotation_signature"));
e.setWalletMigrationStatus(rs.getString("wallet_migration_status"));
e.setMessageMigrationStatus(rs.getString("message_migration_status"));
e.setLastError(rs.getString("last_error"));
long lastErrorAt = rs.getLong("last_error_at_ms");
e.setLastErrorAtMs(rs.wasNull() ? null : lastErrorAt);
e.setRetryCount(rs.getInt("retry_count"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
long completed = rs.getLong("completed_at_ms");
e.setCompletedAtMs(rs.wasNull() ? null : completed);
long aborted = rs.getLong("aborted_at_ms");
e.setAbortedAtMs(rs.wasNull() ? null : aborted);
return e;
}
private static void validateNewSession(KeyRotationSessionEntry e) {
if (e == null) throw new IllegalArgumentException("entry is null");
if (blank(e.getLogin())) throw new IllegalArgumentException("login is required");
if (blank(e.getSourceBlockchainName()) || blank(e.getCandidateBlockchainName())) {
throw new IllegalArgumentException("source/candidate blockchain names are required");
}
if (blank(e.getOldRootKey()) || blank(e.getOldBlockchainKey()) || blank(e.getOldClientKey())
|| blank(e.getNewRootKey()) || blank(e.getNewBlockchainKey()) || blank(e.getNewClientKey())) {
throw new IllegalArgumentException("all old/new public keys are required");
}
if (e.getForkFromHash() == null || e.getForkFromHash().length != 32) {
throw new IllegalArgumentException("forkFromHash must be 32 bytes");
}
if (e.getSourceTipHash() == null || e.getSourceTipHash().length != 32) {
throw new IllegalArgumentException("sourceTipHash must be 32 bytes");
}
if (e.getForkFromBlock() < 0 || e.getSourceTipBlock() < e.getForkFromBlock()) {
throw new IllegalArgumentException("invalid fork/source tip block numbers");
}
if (e.getReasonCode() < 1 || e.getReasonCode() > 4) {
throw new IllegalArgumentException("reasonCode must be 1..4");
}
String comment = e.getComment() == null ? "" : e.getComment();
if (comment.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1024) {
throw new IllegalArgumentException("comment must be <= 1024 UTF-8 bytes");
}
if (e.getProgressCurrent() < 0 || e.getProgressTotal() < e.getProgressCurrent()) {
throw new IllegalArgumentException("invalid initial progress");
}
}
private static boolean blank(String value) {
return value == null || value.isBlank();
}
}
@@ -37,6 +37,27 @@ public final class SolanaUserPdaCurrentDAO {
} }
} }
public SolanaUserPdaCurrentEntry getByLogin(String login) throws SQLException {
String sql = """
SELECT
login,
blockchain_name,
blockchain_key,
paid_limit_bytes
FROM solana_user_pda_current
WHERE LOWER(login) = LOWER(?)
LIMIT 1
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
}
public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
String sql = """ String sql = """
SELECT SELECT
@@ -0,0 +1,45 @@
package shine.db.entities;
/**
* Candidate-блок будущего fork во время смены ключей.
*
* Эти записи намеренно не попадают в обычную таблицу blocks и поэтому
* не влияют на лайки, ответы, каналы и другие materialized state до
* финального переключения активного fork.
*/
public final class KeyRotationCandidateBlockEntry {
private long id;
private long rotationSessionId;
private String login;
private String candidateBlockchainName;
private int blockNumber;
private byte[] blockHash;
private byte[] dataItemId;
private byte[] blockBytes;
private boolean arweavePublishPending;
private Long arweavePublishedAtMs;
private long createdAtMs;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public long getRotationSessionId() { return rotationSessionId; }
public void setRotationSessionId(long rotationSessionId) { this.rotationSessionId = rotationSessionId; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getCandidateBlockchainName() { return candidateBlockchainName; }
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public byte[] getBlockHash() { return blockHash; }
public void setBlockHash(byte[] blockHash) { this.blockHash = blockHash; }
public byte[] getDataItemId() { return dataItemId; }
public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; }
public byte[] getBlockBytes() { return blockBytes; }
public void setBlockBytes(byte[] blockBytes) { this.blockBytes = blockBytes; }
public boolean isArweavePublishPending() { return arweavePublishPending; }
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
}
@@ -0,0 +1,96 @@
package shine.db.entities;
/**
* Публичное/runtime-состояние одной смены ключей.
* Приватные ключи и пароли в этой сущности не хранятся никогда.
*/
public final class KeyRotationSessionEntry {
private long id;
private String login;
private KeyRotationStatus status;
private String sourceBlockchainName;
private String candidateBlockchainName;
private String oldRootKey;
private String oldBlockchainKey;
private String oldClientKey;
private String newRootKey;
private String newBlockchainKey;
private String newClientKey;
private int forkFromBlock;
private byte[] forkFromHash;
private int sourceTipBlock;
private byte[] sourceTipHash;
private short reasonCode;
private String comment;
private int progressCurrent;
private int progressTotal;
private String pdaRotationSignature;
private String walletMigrationStatus;
private String messageMigrationStatus;
private String lastError;
private Long lastErrorAtMs;
private int retryCount;
private long createdAtMs;
private long updatedAtMs;
private Long completedAtMs;
private Long abortedAtMs;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public KeyRotationStatus getStatus() { return status; }
public void setStatus(KeyRotationStatus status) { this.status = status; }
public String getSourceBlockchainName() { return sourceBlockchainName; }
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
public String getCandidateBlockchainName() { return candidateBlockchainName; }
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
public String getOldRootKey() { return oldRootKey; }
public void setOldRootKey(String oldRootKey) { this.oldRootKey = oldRootKey; }
public String getOldBlockchainKey() { return oldBlockchainKey; }
public void setOldBlockchainKey(String oldBlockchainKey) { this.oldBlockchainKey = oldBlockchainKey; }
public String getOldClientKey() { return oldClientKey; }
public void setOldClientKey(String oldClientKey) { this.oldClientKey = oldClientKey; }
public String getNewRootKey() { return newRootKey; }
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
public String getNewBlockchainKey() { return newBlockchainKey; }
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
public String getNewClientKey() { return newClientKey; }
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
public int getForkFromBlock() { return forkFromBlock; }
public void setForkFromBlock(int forkFromBlock) { this.forkFromBlock = forkFromBlock; }
public byte[] getForkFromHash() { return forkFromHash; }
public void setForkFromHash(byte[] forkFromHash) { this.forkFromHash = forkFromHash; }
public int getSourceTipBlock() { return sourceTipBlock; }
public void setSourceTipBlock(int sourceTipBlock) { this.sourceTipBlock = sourceTipBlock; }
public byte[] getSourceTipHash() { return sourceTipHash; }
public void setSourceTipHash(byte[] sourceTipHash) { this.sourceTipHash = sourceTipHash; }
public short getReasonCode() { return reasonCode; }
public void setReasonCode(short reasonCode) { this.reasonCode = reasonCode; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
public int getProgressCurrent() { return progressCurrent; }
public void setProgressCurrent(int progressCurrent) { this.progressCurrent = progressCurrent; }
public int getProgressTotal() { return progressTotal; }
public void setProgressTotal(int progressTotal) { this.progressTotal = progressTotal; }
public String getPdaRotationSignature() { return pdaRotationSignature; }
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
public String getWalletMigrationStatus() { return walletMigrationStatus; }
public void setWalletMigrationStatus(String walletMigrationStatus) { this.walletMigrationStatus = walletMigrationStatus; }
public String getMessageMigrationStatus() { return messageMigrationStatus; }
public void setMessageMigrationStatus(String messageMigrationStatus) { this.messageMigrationStatus = messageMigrationStatus; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public Long getLastErrorAtMs() { return lastErrorAtMs; }
public void setLastErrorAtMs(Long lastErrorAtMs) { this.lastErrorAtMs = lastErrorAtMs; }
public int getRetryCount() { return retryCount; }
public void setRetryCount(int retryCount) { this.retryCount = retryCount; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
public Long getCompletedAtMs() { return completedAtMs; }
public void setCompletedAtMs(Long completedAtMs) { this.completedAtMs = completedAtMs; }
public Long getAbortedAtMs() { return abortedAtMs; }
public void setAbortedAtMs(Long abortedAtMs) { this.abortedAtMs = abortedAtMs; }
}
@@ -0,0 +1,71 @@
package shine.db.entities;
import java.util.EnumSet;
import java.util.Set;
/**
* Состояния серверной машины смены ключей.
*
* NONE используется только в solana_user_pda_current.rotation_status.
* COMPLETE/ABORTED остаются в истории key_rotation_sessions, после чего
* пользователь снова получает rotation_status=NONE.
*/
public enum KeyRotationStatus {
NONE,
COPYING_CHAIN,
CHAIN_READY,
ROTATING_PDA,
PDA_ROTATED,
REBUILDING_SERVER,
WALLET_MIGRATION,
MESSAGE_MIGRATION,
FINALIZING,
COMPLETE,
ABORTED;
public boolean isTerminalSessionStatus() {
return this == COMPLETE || this == ABORTED;
}
public boolean isUserActiveStatus() {
return this != NONE && !isTerminalSessionStatus();
}
/**
* Допустимые переходы одной ротации.
* Abort сознательно разрешён только до начала ROTATING_PDA: после отправки
* Solana-транзакции нельзя надёжно знать, не будет ли она подтверждена позже.
*/
public boolean canTransitionTo(KeyRotationStatus next) {
if (next == null) return false;
return switch (this) {
case COPYING_CHAIN -> EnumSet.of(CHAIN_READY, ABORTED).contains(next);
case CHAIN_READY -> EnumSet.of(ROTATING_PDA, ABORTED).contains(next);
case ROTATING_PDA -> next == PDA_ROTATED;
case PDA_ROTATED -> next == REBUILDING_SERVER;
case REBUILDING_SERVER -> next == WALLET_MIGRATION;
case WALLET_MIGRATION -> next == MESSAGE_MIGRATION;
case MESSAGE_MIGRATION -> next == FINALIZING;
case FINALIZING -> next == COMPLETE;
case NONE, COMPLETE, ABORTED -> false;
};
}
public static Set<KeyRotationStatus> activeStatuses() {
return EnumSet.of(
COPYING_CHAIN,
CHAIN_READY,
ROTATING_PDA,
PDA_ROTATED,
REBUILDING_SERVER,
WALLET_MIGRATION,
MESSAGE_MIGRATION,
FINALIZING
);
}
/** Статус, который должен храниться у пользователя для данной session-state. */
public KeyRotationStatus userStatus() {
return isTerminalSessionStatus() ? NONE : this;
}
}
@@ -0,0 +1,123 @@
-- v26: серверная state machine смены ключей пользователя.
--
-- Важно:
-- - rotation_status / rotation_session_id являются ЛОКАЛЬНЫМ runtime-состоянием сервера,
-- а не полями Solana PDA;
-- - Solana sync не должен перезаписывать эти колонки при upsert PDA;
-- - приватные ключи здесь никогда не хранятся, только публичные ключи ротации.
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
ALTER TABLE solana_user_pda_current
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
rotation_status IN (
'NONE',
'COPYING_CHAIN',
'CHAIN_READY',
'ROTATING_PDA',
'PDA_ROTATED',
'REBUILDING_SERVER',
'WALLET_MIGRATION',
'MESSAGE_MIGRATION',
'FINALIZING'
)
);
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
ON solana_user_pda_current(rotation_status)
WHERE rotation_status <> 'NONE';
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
id BIGSERIAL PRIMARY KEY,
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (
status IN (
'COPYING_CHAIN',
'CHAIN_READY',
'ROTATING_PDA',
'PDA_ROTATED',
'REBUILDING_SERVER',
'WALLET_MIGRATION',
'MESSAGE_MIGRATION',
'FINALIZING',
'COMPLETE',
'ABORTED'
)
),
source_blockchain_name TEXT NOT NULL,
candidate_blockchain_name TEXT NOT NULL,
old_root_key TEXT NOT NULL,
old_blockchain_key TEXT NOT NULL,
old_client_key TEXT NOT NULL,
new_root_key TEXT NOT NULL,
new_blockchain_key TEXT NOT NULL,
new_client_key TEXT NOT NULL,
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
pda_rotation_signature TEXT,
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
wallet_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
),
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
message_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
),
last_error TEXT,
last_error_at_ms BIGINT,
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
completed_at_ms BIGINT,
aborted_at_ms BIGINT,
CHECK (progress_current <= progress_total),
CHECK (fork_from_block <= source_tip_block)
);
-- У одного login одновременно может существовать только одна незавершённая ротация.
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
ON key_rotation_sessions(login)
WHERE status NOT IN ('COMPLETE', 'ABORTED');
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
ON key_rotation_sessions(login, created_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
ON key_rotation_sessions(status);
-- Быстрый указатель из текущего пользователя на активную/последнюю runtime-сессию.
-- FK добавляется после создания key_rotation_sessions, чтобы не создавать циклический DDL порядок.
ALTER TABLE solana_user_pda_current
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
FOREIGN KEY (rotation_session_id)
REFERENCES key_rotation_sessions(id)
ON DELETE SET NULL;
UPDATE db_schema_version
SET schema_version = 26,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE id = 1;
@@ -0,0 +1,31 @@
BEGIN;
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
id BIGSERIAL PRIMARY KEY,
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
login TEXT NOT NULL,
candidate_blockchain_name TEXT NOT NULL,
block_number INTEGER NOT NULL CHECK (block_number >= 0),
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
block_bytes BYTEA NOT NULL,
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
arweave_published_at_ms BIGINT,
created_at_ms BIGINT NOT NULL,
UNIQUE (rotation_session_id, block_number),
UNIQUE (rotation_session_id, data_item_id)
);
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
ON key_rotation_candidate_blocks(id)
WHERE arweave_publish_pending = TRUE;
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
UPDATE db_schema_version
SET schema_version = 27,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE id = 1;
COMMIT;
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
); );
INSERT INTO db_schema_version (id, schema_version, updated_at_ms) INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 24, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) VALUES (1, 26, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version, schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms; updated_at_ms = EXCLUDED.updated_at_ms;
@@ -2081,8 +2081,111 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
ON solana_user_pda_current(is_server, archive_imported) ON solana_user_pda_current(is_server, archive_imported)
WHERE archive_head_tx_id <> ''; WHERE archive_head_tx_id <> '';
-- Server-local key rotation state machine (schema v26).
-- Эти поля не являются частью Solana PDA и не перезаписываются Solana sync upsert-ом.
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
ALTER TABLE solana_user_pda_current
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
rotation_status IN (
'NONE','COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING'
)
);
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
ON solana_user_pda_current(rotation_status)
WHERE rotation_status <> 'NONE';
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
id BIGSERIAL PRIMARY KEY,
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (
status IN (
'COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING',
'COMPLETE','ABORTED'
)
),
source_blockchain_name TEXT NOT NULL,
candidate_blockchain_name TEXT NOT NULL,
old_root_key TEXT NOT NULL,
old_blockchain_key TEXT NOT NULL,
old_client_key TEXT NOT NULL,
new_root_key TEXT NOT NULL,
new_blockchain_key TEXT NOT NULL,
new_client_key TEXT NOT NULL,
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
pda_rotation_signature TEXT,
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
wallet_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
),
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
message_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
),
last_error TEXT,
last_error_at_ms BIGINT,
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
completed_at_ms BIGINT,
aborted_at_ms BIGINT,
CHECK (progress_current <= progress_total),
CHECK (fork_from_block <= source_tip_block)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
ON key_rotation_sessions(login)
WHERE status NOT IN ('COMPLETE','ABORTED');
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
ON key_rotation_sessions(login, created_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
ON key_rotation_sessions(status);
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
id BIGSERIAL PRIMARY KEY,
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
login TEXT NOT NULL,
candidate_blockchain_name TEXT NOT NULL,
block_number INTEGER NOT NULL CHECK (block_number >= 0),
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
block_bytes BYTEA NOT NULL,
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
arweave_published_at_ms BIGINT,
created_at_ms BIGINT NOT NULL,
UNIQUE (rotation_session_id, block_number),
UNIQUE (rotation_session_id, data_item_id)
);
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
ON key_rotation_candidate_blocks(id)
WHERE arweave_publish_pending = TRUE;
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
ALTER TABLE solana_user_pda_current
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
FOREIGN KEY (rotation_session_id)
REFERENCES key_rotation_sessions(id)
ON DELETE SET NULL;
INSERT INTO db_schema_version(id,schema_version,updated_at_ms) INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) VALUES(1,27,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
COMMIT; COMMIT;
@@ -42,8 +42,25 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetMyBlockchain_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStart_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationFinishChain_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationRotatePda_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStatus_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAbort_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationContinue_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
@@ -191,6 +208,16 @@ public final class JsonHandlerRegistry {
// --- blockchain --- // --- blockchain ---
Map.entry("AddBlock", new Net_AddBlock_Handler()), Map.entry("AddBlock", new Net_AddBlock_Handler()),
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()), Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
Map.entry("GetMyBlockchain", new Net_GetMyBlockchain_Handler()),
// --- key rotation ---
Map.entry("KeyRotationStart", new Net_KeyRotationStart_Handler()),
Map.entry("KeyRotationStatus", new Net_KeyRotationStatus_Handler()),
Map.entry("KeyRotationAddBlock", new Net_KeyRotationAddBlock_Handler()),
Map.entry("KeyRotationFinishChain", new Net_KeyRotationFinishChain_Handler()),
Map.entry("KeyRotationRotatePda", new Net_KeyRotationRotatePda_Handler()),
Map.entry("KeyRotationContinue", new Net_KeyRotationContinue_Handler()),
Map.entry("KeyRotationAbort", new Net_KeyRotationAbort_Handler()),
// --- userParams --- // --- userParams ---
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()), Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
@@ -283,6 +310,16 @@ public final class JsonHandlerRegistry {
// --- blockchain --- // --- blockchain ---
Map.entry("AddBlock", Net_AddBlock_Request.class), Map.entry("AddBlock", Net_AddBlock_Request.class),
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class), Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
Map.entry("GetMyBlockchain", Net_GetMyBlockchain_Request.class),
// --- key rotation ---
Map.entry("KeyRotationStart", Net_KeyRotationStart_Request.class),
Map.entry("KeyRotationStatus", Net_KeyRotationStatus_Request.class),
Map.entry("KeyRotationAddBlock", Net_KeyRotationAddBlock_Request.class),
Map.entry("KeyRotationFinishChain", Net_KeyRotationFinishChain_Request.class),
Map.entry("KeyRotationRotatePda", Net_KeyRotationRotatePda_Request.class),
Map.entry("KeyRotationContinue", Net_KeyRotationContinue_Request.class),
Map.entry("KeyRotationAbort", Net_KeyRotationAbort_Request.class),
// --- userParams --- // --- userParams ---
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class), Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
@@ -98,6 +98,18 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
lock.lock(); lock.lock();
try { try {
try {
if (isKeyRotationActive(blockchainName)) {
BlockchainStateEntry currentState = stateDAO.getByBlockchainName(blockchainName);
int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1;
String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : "";
return error(req, 423, "key_rotation_in_progress", lastNum, lastHash);
}
} catch (Exception e) {
log.error("AddBlock: не удалось проверить key rotation status для {}", blockchainName, e);
return error(req, WireCodes.Status.INTERNAL_ERROR, "key_rotation_check_failed", -1, "");
}
AddBlockResult r = addBlock( AddBlockResult r = addBlock(
blockchainName, blockchainName,
req.getBlockNumber(), // старое поле, пока оставляем req.getBlockNumber(), // старое поле, пока оставляем
@@ -152,6 +164,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return resp; return resp;
} }
private boolean isKeyRotationActive(String blockchainName) throws java.sql.SQLException {
String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName);
if (login == null || login.isBlank()) return false;
try (Connection c = shine.db.DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement("""
SELECT rotation_status
FROM solana_user_pda_current
WHERE normalized_login = LOWER(BTRIM(?))
LIMIT 1
""")) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() && !"NONE".equals(rs.getString(1));
}
}
}
private static String humanMessage(String code) { private static String humanMessage(String code) {
if (code == null) return "Ошибка добавления блока"; if (code == null) return "Ошибка добавления блока";
return switch (code) { return switch (code) {
@@ -184,6 +213,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала"; case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется"; case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
case "key_rotation_in_progress" -> "Сейчас выполняется смена ключей; обычные новые блоки временно запрещены";
case "key_rotation_check_failed" -> "Не удалось проверить состояние смены ключей";
default -> "Ошибка: " + code; default -> "Ошибка: " + code;
}; };
} }
@@ -0,0 +1,98 @@
package server.logic.ws_protocol.JSON.handlers.blockchain;
import blockchain.BchBlockEntry;
import blockchain.body.BodyHasTarget;
import server.logic.ws_protocol.Base64Ws;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.dao.SolanaUserPdaCurrentDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.SolanaUserPdaCurrentEntry;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** Authenticated, paginated current-chain view used by "Мой блокчейн" and key rotation. */
public final class Net_GetMyBlockchain_Handler implements JsonMessageHandler {
private final BlocksDAO blocks = BlocksDAO.getInstance();
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_GetMyBlockchain_Request req = (Net_GetMyBlockchain_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
try {
String login = ctx.getLogin().trim();
SolanaUserPdaCurrentEntry user = users.getByLogin(login);
if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) {
return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "Текущий блокчейн пользователя не найден");
}
String bch = user.getBlockchainName();
BlockchainStateEntry state = states.getByBlockchainName(bch);
int tip = state == null ? -1 : state.getLastBlockNumber();
String tipHash = state == null ? null : hex(state.getLastBlockHash());
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(100, req.getLimit()));
int before = req.getBeforeBlock() == null || req.getBeforeBlock() < 0 ? tip : Math.min(tip, req.getBeforeBlock());
boolean includeBytes = Boolean.TRUE.equals(req.getIncludeBlockBytes());
List<BlockEntry> rows = before < 0
? List.of()
: blocks.listRangeByNumber(bch, Math.max(0, before - limit + 1), before);
Collections.reverse(rows);
List<Net_GetMyBlockchain_Response.BlockItem> items = new ArrayList<>(rows.size());
for (BlockEntry row : rows) {
BchBlockEntry parsed = new BchBlockEntry(row.getBlockBytes());
Net_GetMyBlockchain_Response.BlockItem item = new Net_GetMyBlockchain_Response.BlockItem();
item.setBlockNumber(row.getBlockNumber());
item.setBlockHash(hex(row.getBlockHash()));
item.setPrevBlockHash(hex(parsed.prevHash32));
item.setTimestampMs(parsed.timestamp * 1000L);
item.setMsgType(Short.toUnsignedInt(parsed.type));
item.setMsgSubType(Short.toUnsignedInt(parsed.subType));
item.setMsgVersion(Short.toUnsignedInt(parsed.version));
if (parsed.body instanceof BodyHasTarget target) {
item.setToLogin(target.toLogin());
item.setToBlockNumber(target.toBlockGlobalNumber());
item.setToBlockHash(hex(target.toBlockHashBytes()));
}
if (includeBytes) item.setBlockBytesB64(Base64Ws.encode(row.getBlockBytes()));
items.add(item);
}
Net_GetMyBlockchain_Response resp = new Net_GetMyBlockchain_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(login);
resp.setBlockchainName(bch);
resp.setTipBlockNumber(tip);
resp.setTipBlockHash(tipHash);
resp.setBlocks(items);
int lowest = rows.isEmpty() ? -1 : rows.get(rows.size() - 1).getBlockNumber();
resp.setNextBeforeBlock(lowest > 0 ? lowest - 1 : null);
return resp;
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "GET_MY_BLOCKCHAIN_FAILED", "Не удалось прочитать блокчейн пользователя");
}
}
private static String hex(byte[] bytes) {
if (bytes == null) return null;
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b & 0xff));
return sb.toString();
}
}
@@ -0,0 +1,20 @@
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Paginated read of the authenticated user's current materialized blockchain. */
public final class Net_GetMyBlockchain_Request extends Net_Request {
/** Inclusive highest block number to return. Null/<0 means current tip. */
private Integer beforeBlock;
/** 1..100, default 50. */
private Integer limit;
/** Include complete serialized ANS-104 DataItem for rotation/UI inspection. */
private Boolean includeBlockBytes;
public Integer getBeforeBlock() { return beforeBlock; }
public void setBeforeBlock(Integer beforeBlock) { this.beforeBlock = beforeBlock; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public Boolean getIncludeBlockBytes() { return includeBlockBytes; }
public void setIncludeBlockBytes(Boolean includeBlockBytes) { this.includeBlockBytes = includeBlockBytes; }
}
@@ -0,0 +1,64 @@
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public final class Net_GetMyBlockchain_Response extends Net_Response {
private String login;
private String blockchainName;
private int tipBlockNumber;
private String tipBlockHash;
private Integer nextBeforeBlock;
private List<BlockItem> blocks = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getBlockchainName() { return blockchainName; }
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
public int getTipBlockNumber() { return tipBlockNumber; }
public void setTipBlockNumber(int tipBlockNumber) { this.tipBlockNumber = tipBlockNumber; }
public String getTipBlockHash() { return tipBlockHash; }
public void setTipBlockHash(String tipBlockHash) { this.tipBlockHash = tipBlockHash; }
public Integer getNextBeforeBlock() { return nextBeforeBlock; }
public void setNextBeforeBlock(Integer nextBeforeBlock) { this.nextBeforeBlock = nextBeforeBlock; }
public List<BlockItem> getBlocks() { return blocks; }
public void setBlocks(List<BlockItem> blocks) { this.blocks = blocks; }
public static final class BlockItem {
private int blockNumber;
private String blockHash;
private String prevBlockHash;
private long timestampMs;
private int msgType;
private int msgSubType;
private int msgVersion;
private String toLogin;
private Integer toBlockNumber;
private String toBlockHash;
private String blockBytesB64;
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public String getBlockHash() { return blockHash; }
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
public String getPrevBlockHash() { return prevBlockHash; }
public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; }
public long getTimestampMs() { return timestampMs; }
public void setTimestampMs(long timestampMs) { this.timestampMs = timestampMs; }
public int getMsgType() { return msgType; }
public void setMsgType(int msgType) { this.msgType = msgType; }
public int getMsgSubType() { return msgSubType; }
public void setMsgSubType(int msgSubType) { this.msgSubType = msgSubType; }
public int getMsgVersion() { return msgVersion; }
public void setMsgVersion(int msgVersion) { this.msgVersion = msgVersion; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public Integer getToBlockNumber() { return toBlockNumber; }
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
public String getToBlockHash() { return toBlockHash; }
public void setToBlockHash(String toBlockHash) { this.toBlockHash = toBlockHash; }
public String getBlockBytesB64() { return blockBytesB64; }
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
}
}
@@ -105,7 +105,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
String order = asc ? "ASC" : "DESC"; String order = asc ? "ASC" : "DESC";
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>(); List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(""" try (PreparedStatement ps = c.prepareStatement("""
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
to_login, to_bch_name, to_block_number, to_block_hash
FROM blocks FROM blocks
WHERE login = ? AND msg_type = ? WHERE login = ? AND msg_type = ?
ORDER BY block_number ORDER BY block_number
@@ -132,9 +133,9 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
item.setLikedByMe(false); item.setLikedByMe(false);
item.setRepliesCount(0); item.setRepliesCount(0);
item.setRatingsCount(0); item.setRatingsCount(0);
item.setTargetBlockchainName(statusBody.toBchName()); item.setTargetBlockchainName(rs.getString("to_bch_name"));
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber()); item.setTargetBlockNumber((Integer) rs.getObject("to_block_number"));
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes())); item.setTargetBlockHash(ChannelsReadSupport.toHex(rs.getBytes("to_block_hash")));
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem( List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
c, c,
@@ -148,7 +149,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
item.setVersionsTotal(versions.size()); item.setVersionsTotal(versions.size());
item.setText(versions.get(versions.size() - 1).getText()); item.setText(versions.get(versions.size() - 1).getText());
fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes()); fillTargetDetails(c, item, rs.getString("to_bch_name"),
(Integer) rs.getObject("to_block_number"), rs.getBytes("to_block_hash"));
out.add(item); out.add(item);
} }
} }
@@ -215,7 +217,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
return; return;
} }
try (PreparedStatement ps = c.prepareStatement(""" try (PreparedStatement ps = c.prepareStatement("""
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
to_login, to_bch_name, to_block_number, to_block_hash
FROM blocks FROM blocks
WHERE bch_name = ? AND block_number = ? WHERE bch_name = ? AND block_number = ?
LIMIT 1 LIMIT 1
@@ -0,0 +1,120 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationState_Response;
import shine.db.KeyEncodingUtil;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import java.util.Arrays;
import java.util.Base64;
final class KeyRotationApiSupport {
private KeyRotationApiSupport() {}
static String normalizePublicKey32(String raw) {
String normalized = KeyEncodingUtil.normalizeKeyToBase64_32(raw);
if (normalized == null || normalized.isBlank()) {
throw new IllegalArgumentException("public key is empty");
}
try {
byte[] decoded = Base64.getDecoder().decode(normalized);
if (decoded.length != 32) throw new IllegalArgumentException("public key must be 32 bytes");
return Base64.getEncoder().encodeToString(decoded);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("public key must be Base58/Base64 of 32 bytes", e);
}
}
static byte[] parseHash32(String hex) {
if (hex == null) throw new IllegalArgumentException("hash is null");
String s = hex.trim();
if (s.length() != 64) throw new IllegalArgumentException("hash must contain 64 hex chars");
byte[] out = new byte[32];
for (int i = 0; i < 32; i++) {
int hi = Character.digit(s.charAt(i * 2), 16);
int lo = Character.digit(s.charAt(i * 2 + 1), 16);
if (hi < 0 || lo < 0) throw new IllegalArgumentException("hash contains non-hex chars");
out[i] = (byte) ((hi << 4) | lo);
}
return out;
}
static String toHex(byte[] bytes) {
if (bytes == null) return null;
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b & 0xff));
return sb.toString();
}
static String nextBlockchainName(String login, String source) {
if (login == null || login.isBlank() || source == null || source.length() < 5) {
throw new IllegalArgumentException("bad source blockchain name");
}
String prefix = login + "-";
if (!source.startsWith(prefix) || source.length() != prefix.length() + 3) {
throw new IllegalArgumentException("source blockchain name does not match login");
}
String suffix = source.substring(prefix.length());
if (!suffix.chars().allMatch(Character::isDigit)) {
throw new IllegalArgumentException("bad blockchain suffix");
}
int n = Integer.parseInt(suffix);
if (n >= 999) throw new IllegalArgumentException("blockchain fork limit reached");
return login + "-" + String.format("%03d", n + 1);
}
static void requireKeysChangedAndDistinct(String oldRoot, String oldBlockchain, String oldClient,
String newRoot, String newBlockchain, String newClient) {
java.util.Set<String> oldKeys = java.util.Set.of(oldRoot, oldBlockchain, oldClient);
java.util.Set<String> newKeys = java.util.Set.of(newRoot, newBlockchain, newClient);
if (newKeys.size() != 3) {
throw new IllegalArgumentException("new root/blockchain/client keys must be distinct");
}
for (String newKey : newKeys) {
if (oldKeys.contains(newKey)) {
throw new IllegalArgumentException("new keys must not reuse any key from the old key set");
}
}
}
static boolean hashesEqual(byte[] a, byte[] b) {
return Arrays.equals(a, b);
}
static Net_KeyRotationState_Response response(String op, String requestId, KeyRotationSessionEntry e) {
Net_KeyRotationState_Response r = new Net_KeyRotationState_Response();
r.setOp(op);
r.setRequestId(requestId);
r.setStatus(200);
if (e == null) {
r.setRotationStatus(KeyRotationStatus.NONE.name());
return r;
}
r.setRotationSessionId(e.getId());
r.setRotationStatus(e.getStatus().name());
r.setSourceBlockchainName(e.getSourceBlockchainName());
r.setCandidateBlockchainName(e.getCandidateBlockchainName());
r.setOldRootKey(e.getOldRootKey());
r.setOldBlockchainKey(e.getOldBlockchainKey());
r.setOldClientKey(e.getOldClientKey());
r.setNewRootKey(e.getNewRootKey());
r.setNewBlockchainKey(e.getNewBlockchainKey());
r.setNewClientKey(e.getNewClientKey());
r.setForkFromBlock(e.getForkFromBlock());
r.setForkFromHash(toHex(e.getForkFromHash()));
r.setSourceTipBlock(e.getSourceTipBlock());
r.setSourceTipHash(toHex(e.getSourceTipHash()));
r.setReasonCode(e.getReasonCode());
r.setComment(e.getComment());
r.setProgressCurrent(e.getProgressCurrent());
r.setProgressTotal(e.getProgressTotal());
r.setPdaRotationSignature(e.getPdaRotationSignature());
r.setWalletMigrationStatus(e.getWalletMigrationStatus());
r.setMessageMigrationStatus(e.getMessageMigrationStatus());
r.setLastError(e.getLastError());
r.setRetryCount(e.getRetryCount());
r.setCreatedAtMs(e.getCreatedAtMs());
r.setUpdatedAtMs(e.getUpdatedAtMs());
return r;
}
}
@@ -0,0 +1,36 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
/** Прерывание разрешено только пока Solana-транзакция ротации ещё не могла быть отправлена. */
public final class Net_KeyRotationAbort_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationAbort_Request req = (Net_KeyRotationAbort_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
try {
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
if (current == null) return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), null);
if (current.getStatus() != KeyRotationStatus.COPYING_CHAIN && current.getStatus() != KeyRotationStatus.CHAIN_READY) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_ABORT_TOO_LATE",
"После начала ротации PDA процесс можно только завершить");
}
KeyRotationSessionEntry aborted = rotations.transition(current.getId(), current.getStatus(), KeyRotationStatus.ABORTED);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), aborted);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ABORT_FAILED", "Не удалось прервать смену ключей");
}
}
}
@@ -0,0 +1,224 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import blockchain.BchBlockEntry;
import blockchain.BchCryptoVerifier;
import blockchain.MsgSubType;
import blockchain.body.ForkBody;
import server.logic.ws_protocol.Base64Ws;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.DbController;
import shine.db.dao.BlocksDAO;
import shine.db.dao.KeyRotationCandidateBlocksDAO;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import java.sql.Connection;
import java.util.Arrays;
import java.util.Base64;
import java.util.concurrent.locks.ReentrantLock;
/**
* Принимает DataItem будущего fork во время COPYING_CHAIN.
* Candidate-блок сохраняется отдельно от blocks и не влияет на materialized state.
*/
public final class Net_KeyRotationAddBlock_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
private final BlocksDAO blocks = BlocksDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationAddBlock_Request req = (Net_KeyRotationAddBlock_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
if (req.getBlockNumber() < 0 || req.getBlockBytesB64() == null || req.getBlockBytesB64().isBlank()) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "blockNumber и blockBytesB64 обязательны");
}
String login = ctx.getLogin().trim();
KeyRotationSessionEntry initial;
try {
initial = rotations.getActiveByLogin(login);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_DB_ERROR", "Не удалось прочитать состояние смены ключей");
}
if (initial == null || initial.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация не находится на этапе COPYING_CHAIN");
}
ReentrantLock lock = BlockchainLocks.lockFor(initial.getCandidateBlockchainName());
lock.lock();
try (Connection c = DbController.getInstance().getConnection()) {
KeyRotationSessionEntry session = rotations.getActiveByLogin(c, login);
if (session == null || session.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация больше не находится на этапе COPYING_CHAIN");
}
int stored = candidates.countStored(c, session.getId());
if (req.getBlockNumber() < stored) {
KeyRotationCandidateBlockEntry existing = candidates.getByNumber(c, session.getId(), req.getBlockNumber());
if (existing == null) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CANDIDATE_GAP", "Нарушена последовательность candidate-блоков");
}
BchBlockEntry repeated;
try { repeated = new BchBlockEntry(Base64Ws.decode(req.getBlockBytesB64())); }
catch (Exception e) { return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "Не удалось распарсить DataItem"); }
if (!Arrays.equals(existing.getBlockHash(), repeated.getHash32())
|| !Arrays.equals(existing.getDataItemId(), repeated.getDataItemId32())) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_CONFLICT", "На этом номере уже сохранён другой candidate-блок");
}
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), session);
}
if (req.getBlockNumber() != stored) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_OUT_OF_ORDER", "Candidate-блоки нужно добавлять последовательно с block 0");
}
if (stored >= session.getProgressTotal()) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_ALREADY_COMPLETE", "Все candidate-блоки уже приняты сервером");
}
final byte[] raw;
final BchBlockEntry block;
try {
raw = Base64Ws.decode(req.getBlockBytesB64());
block = new BchBlockEntry(raw);
block.body.check();
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "Некорректный SHiNE/ANS-104 блок");
}
if (block.blockNumber != req.getBlockNumber()) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BLOCK_NUMBER_MISMATCH", "blockNumber запроса не совпадает с блоком");
}
if (!block.getDataItem().hasTag("App", "test5590")) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_APP_TAG", "Отсутствует App=test5590");
}
if (!requestPrevHashMatches(req.getPrevBlockHash(), block.prevHash32)) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_PREV_HASH_MISMATCH", "prevBlockHash запроса не совпадает с блоком");
}
byte[] newBlockchainKey;
try {
newBlockchainKey = Base64.getDecoder().decode(session.getNewBlockchainKey());
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_BAD_STORED_KEY", "Некорректный new blockchain public key в rotation session");
}
if (newBlockchainKey.length != 32 || !BchCryptoVerifier.verifyBlock(block, newBlockchainKey)) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_SIGNATURE", "Candidate DataItem должен быть подписан новым blockchain key");
}
String validationError;
try {
validationError = validateCandidate(c, session, block);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_VALIDATE_FAILED", "Не удалось проверить candidate-блок");
}
if (validationError != null) {
return NetExceptionResponseFactory.error(req, 400, validationError, "Candidate-блок не соответствует выбранному fork");
}
KeyRotationCandidateBlockEntry entry = new KeyRotationCandidateBlockEntry();
entry.setRotationSessionId(session.getId());
entry.setLogin(login);
entry.setCandidateBlockchainName(session.getCandidateBlockchainName());
entry.setBlockNumber(block.blockNumber);
entry.setBlockHash(block.getHash32());
entry.setDataItemId(block.getDataItemId32());
entry.setBlockBytes(raw);
entry.setCreatedAtMs(System.currentTimeMillis());
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
candidates.insertOrGet(c, entry);
c.commit();
} catch (Exception e) {
c.rollback();
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_CONFLICT", "Не удалось сохранить candidate-блок");
} finally {
c.setAutoCommit(oldAutoCommit);
}
// progressCurrent обновляет Arweave publisher только после реальной публикации.
KeyRotationSessionEntry refreshed = rotations.getActiveByLogin(login);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), refreshed == null ? session : refreshed);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ADD_BLOCK_FAILED", "Не удалось принять candidate-блок");
} finally {
lock.unlock();
}
}
private String validateCandidate(Connection c, KeyRotationSessionEntry session, BchBlockEntry candidate) throws Exception {
if (candidate.blockNumber <= session.getForkFromBlock()) {
BlockEntry sourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), candidate.blockNumber);
if (sourceEntry == null || sourceEntry.getBlockBytes() == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
BchBlockEntry source = new BchBlockEntry(sourceEntry.getBlockBytes());
// Перепубликуется тот же Frame и те же ANS-104 tags/target/anchor; меняются только owner/signature/DataItem id.
if (!Arrays.equals(source.getFrameBytes(), candidate.getFrameBytes())) return "KEY_ROTATION_FRAME_MISMATCH";
if (!Arrays.equals(source.getDataItem().rawTags(), candidate.getDataItem().rawTags())) return "KEY_ROTATION_TAGS_MISMATCH";
if (!Arrays.equals(source.getDataItem().target(), candidate.getDataItem().target())) return "KEY_ROTATION_TARGET_MISMATCH";
if (!Arrays.equals(source.getDataItem().anchor(), candidate.getDataItem().anchor())) return "KEY_ROTATION_ANCHOR_MISMATCH";
return null;
}
int techForkNumber = session.getForkFromBlock() + 1;
if (candidate.blockNumber != techForkNumber) return "KEY_ROTATION_UNEXPECTED_BLOCK_NUMBER";
if ((candidate.type & 0xFFFF) != 0
|| (candidate.subType & 0xFFFF) != (MsgSubType.TECH_FORK & 0xFFFF)
|| !(candidate.body instanceof ForkBody fork)) {
return "KEY_ROTATION_TECH_FORK_REQUIRED";
}
if (!Arrays.equals(candidate.prevHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_PREV_HASH";
BlockEntry forkSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getForkFromBlock());
BlockEntry tipSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getSourceTipBlock());
if (forkSourceEntry == null || tipSourceEntry == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
BchBlockEntry forkSource = new BchBlockEntry(forkSourceEntry.getBlockBytes());
BchBlockEntry tipSource = new BchBlockEntry(tipSourceEntry.getBlockBytes());
byte[] oldBlockchain = Base64.getDecoder().decode(session.getOldBlockchainKey());
if (!Arrays.equals(fork.parentBlockchainKey32, oldBlockchain)) return "KEY_ROTATION_TECH_FORK_PARENT_KEY";
if (fork.forkPointBlockNumber != session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_POINT";
if (!Arrays.equals(fork.forkPointBlockHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_POINT_HASH";
if (fork.forkPointTimestampMs != Math.multiplyExact(forkSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_POINT_TIME";
if (fork.parentTipBlockNumber != session.getSourceTipBlock()) return "KEY_ROTATION_TECH_FORK_TIP";
if (!Arrays.equals(fork.parentTipBlockHash32, session.getSourceTipHash())) return "KEY_ROTATION_TECH_FORK_TIP_HASH";
if (fork.parentTipTimestampMs != Math.multiplyExact(tipSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_TIP_TIME";
if (fork.discardedBlocksCount != session.getSourceTipBlock() - session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_DISCARDED";
if (fork.reasonCode != session.getReasonCode()) return "KEY_ROTATION_TECH_FORK_REASON";
if (!normalizeComment(fork.comment).equals(normalizeComment(session.getComment()))) return "KEY_ROTATION_TECH_FORK_COMMENT";
return null;
}
private static boolean requestPrevHashMatches(String raw, byte[] actual) {
if (raw == null || raw.isBlank()) {
return isZero32(actual);
}
try {
return Arrays.equals(KeyRotationApiSupport.parseHash32(raw), actual);
} catch (Exception e) {
return false;
}
}
private static boolean isZero32(byte[] value) {
if (value == null || value.length != 32) return false;
for (byte b : value) if (b != 0) return false;
return true;
}
private static String normalizeComment(String value) {
if (value == null) return "";
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
}
}
@@ -0,0 +1,53 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
/**
* Сейчас wallet/DM migration — явные заглушки. Continue фиксирует NOT_IMPLEMENTED
* и переводит state machine дальше, не делая вид, что данные были реально мигрированы.
*/
public final class Net_KeyRotationContinue_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationContinue_Request req = (Net_KeyRotationContinue_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
try {
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
if (current == null) return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), null);
if (current.getStatus() == KeyRotationStatus.WALLET_MIGRATION) {
rotations.setMigrationSubStatus(current.getId(), "wallet_migration_status", "NOT_IMPLEMENTED");
current = rotations.transition(current.getId(), KeyRotationStatus.WALLET_MIGRATION, KeyRotationStatus.MESSAGE_MIGRATION);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
}
if (current.getStatus() == KeyRotationStatus.MESSAGE_MIGRATION) {
rotations.setMigrationSubStatus(current.getId(), "message_migration_status", "NOT_IMPLEMENTED");
current = rotations.transition(current.getId(), KeyRotationStatus.MESSAGE_MIGRATION, KeyRotationStatus.FINALIZING);
// FINALIZING пока не содержит отдельной пользовательской работы: сразу завершаем.
current = rotations.transition(current.getId(), KeyRotationStatus.FINALIZING, KeyRotationStatus.COMPLETE);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
}
if (current.getStatus() == KeyRotationStatus.FINALIZING) {
current = rotations.transition(current.getId(), KeyRotationStatus.FINALIZING, KeyRotationStatus.COMPLETE);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
}
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CONTINUE_NOT_ALLOWED",
"На текущем этапе продолжение этой операцией не требуется");
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_CONTINUE_FAILED",
"Не удалось продолжить смену ключей");
}
}
}
@@ -0,0 +1,218 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import blockchain.BchBlockEntry;
import blockchain.BchCryptoVerifier;
import blockchain.MsgSubType;
import blockchain.body.ForkBody;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.DbController;
import shine.db.dao.BlocksDAO;
import shine.db.dao.KeyRotationCandidateBlocksDAO;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;
/**
* Проверяет, что candidate-chain полностью сохранена и опубликована в Arweave/Turbo,
* затем переводит ротацию COPYING_CHAIN -> CHAIN_READY.
*/
public final class Net_KeyRotationFinishChain_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_KeyRotationFinishChain_Handler.class);
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
private final BlocksDAO blocks = BlocksDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationFinishChain_Request req = (Net_KeyRotationFinishChain_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
String login = ctx.getLogin().trim();
KeyRotationSessionEntry initial;
try {
initial = rotations.getActiveByLogin(login);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_DB_ERROR", "Не удалось прочитать состояние смены ключей");
}
if (initial == null) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
}
if (initial.getStatus() == KeyRotationStatus.CHAIN_READY) {
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), initial);
}
if (initial.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация не находится на этапе COPYING_CHAIN");
}
ReentrantLock lock = BlockchainLocks.lockFor(initial.getCandidateBlockchainName());
lock.lock();
try (Connection c = DbController.getInstance().getConnection()) {
KeyRotationSessionEntry session = rotations.getActiveByLogin(c, login);
if (session == null) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
}
if (session.getStatus() == KeyRotationStatus.CHAIN_READY) {
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), session);
}
if (session.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация больше не находится на этапе COPYING_CHAIN");
}
int expected = session.getProgressTotal();
int stored = candidates.countStored(c, session.getId());
int published = candidates.countPublished(c, session.getId());
if (stored != expected) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_INCOMPLETE",
"Candidate-chain ещё не полностью загружена: " + stored + "/" + expected);
}
if (published != expected) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_NOT_PUBLISHED",
"Candidate-chain ещё не полностью опубликована в Arweave/Turbo: " + published + "/" + expected);
}
List<KeyRotationCandidateBlockEntry> entries = candidates.listBySession(c, session.getId());
String validationError = validateCompleteChain(c, session, entries);
if (validationError != null) {
try { rotations.recordError(session.getId(), validationError); } catch (Exception ignored) { }
return NetExceptionResponseFactory.error(req, 409, validationError, "Финальная проверка candidate-chain не пройдена");
}
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_FINISH_CHAIN_FAILED", "Не удалось завершить проверку candidate-chain");
} finally {
lock.unlock();
}
try {
KeyRotationSessionEntry ready = rotations.transition(
initial.getId(), KeyRotationStatus.COPYING_CHAIN, KeyRotationStatus.CHAIN_READY);
rotations.clearError(initial.getId());
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), ready);
} catch (Exception transitionFailure) {
log.warn("KeyRotationFinishChain transition failed: login={}, sessionId={}",
login, initial.getId(), transitionFailure);
// Идемпотентность для параллельного FinishChain из другой сессии.
try {
KeyRotationSessionEntry now = rotations.getActiveByLogin(login);
if (now != null && now.getStatus() == KeyRotationStatus.CHAIN_READY) {
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), now);
}
} catch (Exception ignored) { }
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_FINISH_CHAIN_RACE", "Состояние ротации изменилось во время FinishChain");
}
}
private String validateCompleteChain(Connection c,
KeyRotationSessionEntry session,
List<KeyRotationCandidateBlockEntry> entries) throws Exception {
if (entries == null || entries.size() != session.getProgressTotal()) return "KEY_ROTATION_CHAIN_INCOMPLETE";
if (session.getProgressTotal() != session.getForkFromBlock() + 2) return "KEY_ROTATION_BAD_PROGRESS_TOTAL";
byte[] newBlockchainKey = Base64.getDecoder().decode(session.getNewBlockchainKey());
if (newBlockchainKey.length != 32) return "KEY_ROTATION_BAD_STORED_KEY";
BchBlockEntry previous = null;
for (int i = 0; i < entries.size(); i++) {
KeyRotationCandidateBlockEntry stored = entries.get(i);
if (stored.getBlockNumber() != i) return "KEY_ROTATION_CANDIDATE_GAP";
if (stored.isArweavePublishPending() || stored.getArweavePublishedAtMs() == null) {
return "KEY_ROTATION_CHAIN_NOT_PUBLISHED";
}
BchBlockEntry candidate;
try {
candidate = new BchBlockEntry(stored.getBlockBytes());
candidate.body.check();
} catch (Exception e) {
return "KEY_ROTATION_BAD_STORED_BLOCK";
}
if (candidate.blockNumber != i) return "KEY_ROTATION_BLOCK_NUMBER_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 (!candidate.getDataItem().hasTag("App", "test5590")) return "KEY_ROTATION_BAD_APP_TAG";
if (!BchCryptoVerifier.verifyBlock(candidate, newBlockchainKey)) return "KEY_ROTATION_BAD_SIGNATURE";
if (i == 0) {
if (!isZero32(candidate.prevHash32)) return "KEY_ROTATION_GENESIS_PREV_HASH";
} else {
if (previous == null || !Arrays.equals(candidate.prevHash32, previous.getHash32())) {
return "KEY_ROTATION_CHAIN_HASH_MISMATCH";
}
}
String semanticError = validateAgainstRotation(c, session, candidate);
if (semanticError != null) return semanticError;
previous = candidate;
}
return null;
}
private String validateAgainstRotation(Connection c, KeyRotationSessionEntry session, BchBlockEntry candidate) throws Exception {
if (candidate.blockNumber <= session.getForkFromBlock()) {
BlockEntry sourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), candidate.blockNumber);
if (sourceEntry == null || sourceEntry.getBlockBytes() == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
BchBlockEntry source = new BchBlockEntry(sourceEntry.getBlockBytes());
if (!Arrays.equals(source.getFrameBytes(), candidate.getFrameBytes())) return "KEY_ROTATION_FRAME_MISMATCH";
if (!Arrays.equals(source.getDataItem().rawTags(), candidate.getDataItem().rawTags())) return "KEY_ROTATION_TAGS_MISMATCH";
if (!Arrays.equals(source.getDataItem().target(), candidate.getDataItem().target())) return "KEY_ROTATION_TARGET_MISMATCH";
if (!Arrays.equals(source.getDataItem().anchor(), candidate.getDataItem().anchor())) return "KEY_ROTATION_ANCHOR_MISMATCH";
return null;
}
if (candidate.blockNumber != session.getForkFromBlock() + 1) return "KEY_ROTATION_UNEXPECTED_BLOCK_NUMBER";
if ((candidate.type & 0xFFFF) != 0
|| (candidate.subType & 0xFFFF) != (MsgSubType.TECH_FORK & 0xFFFF)
|| !(candidate.body instanceof ForkBody fork)) {
return "KEY_ROTATION_TECH_FORK_REQUIRED";
}
if (!Arrays.equals(candidate.prevHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_PREV_HASH";
BlockEntry forkSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getForkFromBlock());
BlockEntry tipSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getSourceTipBlock());
if (forkSourceEntry == null || tipSourceEntry == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
BchBlockEntry forkSource = new BchBlockEntry(forkSourceEntry.getBlockBytes());
BchBlockEntry tipSource = new BchBlockEntry(tipSourceEntry.getBlockBytes());
byte[] oldBlockchain = Base64.getDecoder().decode(session.getOldBlockchainKey());
if (!Arrays.equals(fork.parentBlockchainKey32, oldBlockchain)) return "KEY_ROTATION_TECH_FORK_PARENT_KEY";
if (fork.forkPointBlockNumber != session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_POINT";
if (!Arrays.equals(fork.forkPointBlockHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_POINT_HASH";
if (fork.forkPointTimestampMs != Math.multiplyExact(forkSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_POINT_TIME";
if (fork.parentTipBlockNumber != session.getSourceTipBlock()) return "KEY_ROTATION_TECH_FORK_TIP";
if (!Arrays.equals(fork.parentTipBlockHash32, session.getSourceTipHash())) return "KEY_ROTATION_TECH_FORK_TIP_HASH";
if (fork.parentTipTimestampMs != Math.multiplyExact(tipSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_TIP_TIME";
if (fork.discardedBlocksCount != session.getSourceTipBlock() - session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_DISCARDED";
if (fork.reasonCode != session.getReasonCode()) return "KEY_ROTATION_TECH_FORK_REASON";
if (!normalizeComment(fork.comment).equals(normalizeComment(session.getComment()))) return "KEY_ROTATION_TECH_FORK_COMMENT";
return null;
}
private static boolean isZero32(byte[] value) {
if (value == null || value.length != 32) return false;
for (byte b : value) if (b != 0) return false;
return true;
}
private static String normalizeComment(String value) {
if (value == null) return "";
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
}
}
@@ -0,0 +1,83 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
/**
* Фиксирует уже отправленную клиентом Solana-транзакцию ротации PDA.
* Приватные ключи и подписанная транзакция через сервер не проходят: клиент передаёт только tx signature.
*/
public final class Net_KeyRotationRotatePda_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationRotatePda_Request req = (Net_KeyRotationRotatePda_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
String login = ctx.getLogin().trim();
final String signature;
try {
signature = normalizeSolanaSignature(req.getPdaRotationSignature());
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_SOLANA_SIGNATURE", e.getMessage());
}
try {
KeyRotationSessionEntry current = rotations.getActiveByLogin(login);
if (current == null) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
}
if (current.getStatus() == KeyRotationStatus.PDA_ROTATED) {
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
}
if (current.getStatus() == KeyRotationStatus.ROTATING_PDA) {
String stored = current.getPdaRotationSignature();
if (stored != null && !stored.isBlank() && !stored.equals(signature)) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_PDA_SIGNATURE_CONFLICT",
"Для этой ротации уже сохранена другая Solana transaction signature");
}
if (stored == null || stored.isBlank()) {
rotations.setPdaRotationSignature(current.getId(), signature);
}
} else if (current.getStatus() == KeyRotationStatus.CHAIN_READY) {
current = rotations.beginPdaRotation(current.getId(), signature);
} else {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_CHAIN_READY",
"PDA можно ротировать только после CHAIN_READY");
}
// Solana sync мог увидеть новое PDA ещё до этого API-вызова.
KeyRotationSessionEntry confirmed = rotations.tryMarkPdaRotatedFromCurrentState(current.getId());
rotations.clearError(current.getId());
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), confirmed != null ? confirmed : rotations.getById(current.getId()));
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ROTATE_PDA_FAILED",
"Не удалось зафиксировать Solana-ротацию PDA");
}
}
private static String normalizeSolanaSignature(String raw) {
if (raw == null || raw.isBlank()) throw new IllegalArgumentException("pdaRotationSignature обязательна");
String s = raw.trim();
if (s.length() > 128) throw new IllegalArgumentException("Слишком длинная Solana transaction signature");
final String alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
for (int i = 0; i < s.length(); i++) {
if (alphabet.indexOf(s.charAt(i)) < 0) {
throw new IllegalArgumentException("Solana transaction signature должна быть Base58");
}
}
return s;
}
}
@@ -0,0 +1,209 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.DbController;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.concurrent.locks.ReentrantLock;
/**
* Создаёт серверную ротацию сразу в COPYING_CHAIN.
* До этого момента всё заполнение формы остаётся только локальным UI-состоянием.
*/
public final class Net_KeyRotationStart_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
private final BlocksDAO blocks = BlocksDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationStart_Request req = (Net_KeyRotationStart_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
String login = ctx.getLogin().trim();
String ctxBlockchainName = ctx.getCurrentUser() != null ? ctx.getCurrentUser().getBlockchainName() : null;
if (ctxBlockchainName == null || ctxBlockchainName.isBlank()) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_SESSION_STALE",
"В сессии отсутствует актуальный blockchainName; войдите заново");
}
final String newRoot;
final String newBlockchain;
final String newClient;
final byte[] requestedForkHash;
try {
newRoot = KeyRotationApiSupport.normalizePublicKey32(req.getNewRootKey());
newBlockchain = KeyRotationApiSupport.normalizePublicKey32(req.getNewBlockchainKey());
newClient = KeyRotationApiSupport.normalizePublicKey32(req.getNewClientKey());
requestedForkHash = KeyRotationApiSupport.parseHash32(req.getForkFromHash());
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FIELDS", e.getMessage());
}
if (req.getReasonCode() < 1 || req.getReasonCode() > 4) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_REASON", "reasonCode должен быть 1..4");
}
String comment = req.getComment() == null ? "" : req.getComment();
if (comment.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1024) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_COMMENT_TOO_LONG", "Комментарий должен быть не длиннее 1024 UTF-8 байт");
}
if (req.getForkFromBlock() < 0) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "forkFromBlock должен быть >= 0");
}
ReentrantLock lock = BlockchainLocks.lockFor(ctxBlockchainName);
lock.lock();
try (Connection c = DbController.getInstance().getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
PdaSnapshot pda = loadPdaForUpdate(c, login);
if (pda == null) {
c.rollback();
return NetExceptionResponseFactory.error(req, 404, "KEY_ROTATION_USER_NOT_FOUND", "Пользователь не найден в текущем Solana PDA state");
}
if (!KeyRotationStatus.NONE.name().equals(pda.rotationStatus)) {
KeyRotationSessionEntry active = rotations.getActiveByLogin(c, login);
c.rollback();
if (active != null
&& sameStartRequest(active, newRoot, newBlockchain, newClient,
req.getForkFromBlock(), requestedForkHash, req.getReasonCode(), comment)) {
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), active);
}
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_ALREADY_ACTIVE", "Смена ключей уже выполняется с другими параметрами");
}
if (!ctxBlockchainName.equals(pda.blockchainName)) {
c.rollback();
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_SESSION_STALE",
"Активный blockchain изменился; обновите сессию и повторите");
}
String oldRoot = KeyRotationApiSupport.normalizePublicKey32(pda.rootKey);
String oldBlockchain = KeyRotationApiSupport.normalizePublicKey32(pda.blockchainKey);
String oldClient = KeyRotationApiSupport.normalizePublicKey32(pda.clientKey);
try {
KeyRotationApiSupport.requireKeysChangedAndDistinct(
oldRoot, oldBlockchain, oldClient, newRoot, newBlockchain, newClient);
} catch (IllegalArgumentException e) {
c.rollback();
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_NEW_KEYS", e.getMessage());
}
BlockchainStateEntry state = states.getByBlockchainName(c, pda.blockchainName);
if (state == null || state.getLastBlockHash() == null || state.getLastBlockHash().length != 32) {
c.rollback();
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_STATE_MISSING", "Не найдено корректное текущее состояние блокчейна");
}
if (req.getForkFromBlock() > state.getLastBlockNumber()) {
c.rollback();
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "Точка fork находится после текущего tip");
}
BlockEntry forkBlock = blocks.getByNumber(c, pda.blockchainName, req.getForkFromBlock());
if (forkBlock == null || forkBlock.getBlockHash() == null || forkBlock.getBlockHash().length != 32) {
c.rollback();
return NetExceptionResponseFactory.error(req, 404, "KEY_ROTATION_FORK_BLOCK_NOT_FOUND", "Выбранный блок не найден на сервере");
}
if (!KeyRotationApiSupport.hashesEqual(forkBlock.getBlockHash(), requestedForkHash)) {
c.rollback();
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_FORK_HASH_MISMATCH", "Хэш выбранного блока не совпадает с сервером");
}
final String candidateName;
try {
candidateName = KeyRotationApiSupport.nextBlockchainName(login, pda.blockchainName);
} catch (IllegalArgumentException e) {
c.rollback();
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCKCHAIN_NAME", e.getMessage());
}
KeyRotationSessionEntry entry = new KeyRotationSessionEntry();
entry.setLogin(login);
entry.setSourceBlockchainName(pda.blockchainName);
entry.setCandidateBlockchainName(candidateName);
entry.setOldRootKey(oldRoot);
entry.setOldBlockchainKey(oldBlockchain);
entry.setOldClientKey(oldClient);
entry.setNewRootKey(newRoot);
entry.setNewBlockchainKey(newBlockchain);
entry.setNewClientKey(newClient);
entry.setForkFromBlock(req.getForkFromBlock());
entry.setForkFromHash(requestedForkHash);
entry.setSourceTipBlock(state.getLastBlockNumber());
entry.setSourceTipHash(state.getLastBlockHash());
entry.setReasonCode(req.getReasonCode());
entry.setComment(comment);
entry.setProgressCurrent(0);
entry.setProgressTotal(Math.addExact(req.getForkFromBlock(), 2)); // 0..N + TECH_FORK
KeyRotationSessionEntry created = rotations.createCopyingSession(c, entry);
c.commit();
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), created);
} catch (ArithmeticException e) {
c.rollback();
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "Слишком большой номер блока");
} catch (Exception e) {
c.rollback();
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_START_FAILED", "Не удалось запустить смену ключей");
} finally {
c.setAutoCommit(oldAutoCommit);
}
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_START_FAILED", "Не удалось открыть транзакцию смены ключей");
} finally {
lock.unlock();
}
}
private static boolean sameStartRequest(KeyRotationSessionEntry active,
String newRoot, String newBlockchain, String newClient,
int forkFromBlock, byte[] forkFromHash, short reasonCode, String comment) {
return active != null
&& java.util.Objects.equals(active.getNewRootKey(), newRoot)
&& java.util.Objects.equals(active.getNewBlockchainKey(), newBlockchain)
&& java.util.Objects.equals(active.getNewClientKey(), newClient)
&& active.getForkFromBlock() == forkFromBlock
&& java.util.Arrays.equals(active.getForkFromHash(), forkFromHash)
&& active.getReasonCode() == reasonCode
&& java.util.Objects.equals(active.getComment() == null ? "" : active.getComment(), comment == null ? "" : comment);
}
private static PdaSnapshot loadPdaForUpdate(Connection c, String login) throws Exception {
try (PreparedStatement ps = c.prepareStatement("""
SELECT root_key, blockchain_key, client_key, blockchain_name, rotation_status
FROM solana_user_pda_current
WHERE normalized_login = LOWER(BTRIM(?))
FOR UPDATE
""")) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return new PdaSnapshot(
rs.getString("root_key"),
rs.getString("blockchain_key"),
rs.getString("client_key"),
rs.getString("blockchain_name"),
rs.getString("rotation_status")
);
}
}
}
private record PdaSnapshot(String rootKey, String blockchainKey, String clientKey,
String blockchainName, String rotationStatus) { }
}
@@ -0,0 +1,29 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.KeyRotationSessionEntry;
public final class Net_KeyRotationStatus_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationStatus_Request req = (Net_KeyRotationStatus_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
try {
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_STATUS_FAILED",
"Не удалось прочитать состояние смены ключей");
}
}
}
@@ -0,0 +1,7 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Прервать ротацию до отправки Solana-транзакции. */
public final class Net_KeyRotationAbort_Request extends Net_Request {
}
@@ -0,0 +1,17 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Один подписанный новым blockchain key DataItem будущего fork. */
public final class Net_KeyRotationAddBlock_Request extends Net_Request {
private int blockNumber;
private String prevBlockHash;
private String blockBytesB64;
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public String getPrevBlockHash() { return prevBlockHash; }
public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; }
public String getBlockBytesB64() { return blockBytesB64; }
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
}
@@ -0,0 +1,7 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Продолжить один из интерактивных/заглушечных этапов после rebuild. */
public final class Net_KeyRotationContinue_Request extends Net_Request {
}
@@ -0,0 +1,7 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Финальная проверка полностью опубликованной candidate-chain. */
public final class Net_KeyRotationFinishChain_Request extends Net_Request {
}
@@ -0,0 +1,14 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Клиент уже локально подписал и отправил Solana-транзакцию ротации PDA.
* Серверу передаётся только публичная Solana transaction signature для аудита/возобновления.
*/
public final class Net_KeyRotationRotatePda_Request extends Net_Request {
private String pdaRotationSignature;
public String getPdaRotationSignature() { return pdaRotationSignature; }
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
}
@@ -0,0 +1,32 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запуск смены ключей. Старые ключи сервер берёт из текущего Solana PDA;
* клиент передаёт только новые публичные ключи и выбранную точку fork.
*/
public final class Net_KeyRotationStart_Request extends Net_Request {
private String newRootKey;
private String newBlockchainKey;
private String newClientKey;
private int forkFromBlock;
private String forkFromHash;
private short reasonCode;
private String comment;
public String getNewRootKey() { return newRootKey; }
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
public String getNewBlockchainKey() { return newBlockchainKey; }
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
public String getNewClientKey() { return newClientKey; }
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
public int getForkFromBlock() { return forkFromBlock; }
public void setForkFromBlock(int forkFromBlock) { this.forkFromBlock = forkFromBlock; }
public String getForkFromHash() { return forkFromHash; }
public void setForkFromHash(String forkFromHash) { this.forkFromHash = forkFromHash; }
public short getReasonCode() { return reasonCode; }
public void setReasonCode(short reasonCode) { this.reasonCode = reasonCode; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
}
@@ -0,0 +1,83 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/** Публичное состояние текущей/только что созданной ротации. */
public final class Net_KeyRotationState_Response extends Net_Response {
private Long rotationSessionId;
private String rotationStatus;
private String sourceBlockchainName;
private String candidateBlockchainName;
private String oldRootKey;
private String oldBlockchainKey;
private String oldClientKey;
private String newRootKey;
private String newBlockchainKey;
private String newClientKey;
private Integer forkFromBlock;
private String forkFromHash;
private Integer sourceTipBlock;
private String sourceTipHash;
private Short reasonCode;
private String comment;
private Integer progressCurrent;
private Integer progressTotal;
private String pdaRotationSignature;
private String walletMigrationStatus;
private String messageMigrationStatus;
private String lastError;
private Integer retryCount;
private Long createdAtMs;
private Long updatedAtMs;
public Long getRotationSessionId() { return rotationSessionId; }
public void setRotationSessionId(Long rotationSessionId) { this.rotationSessionId = rotationSessionId; }
public String getRotationStatus() { return rotationStatus; }
public void setRotationStatus(String rotationStatus) { this.rotationStatus = rotationStatus; }
public String getSourceBlockchainName() { return sourceBlockchainName; }
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
public String getCandidateBlockchainName() { return candidateBlockchainName; }
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
public String getOldRootKey() { return oldRootKey; }
public void setOldRootKey(String oldRootKey) { this.oldRootKey = oldRootKey; }
public String getOldBlockchainKey() { return oldBlockchainKey; }
public void setOldBlockchainKey(String oldBlockchainKey) { this.oldBlockchainKey = oldBlockchainKey; }
public String getOldClientKey() { return oldClientKey; }
public void setOldClientKey(String oldClientKey) { this.oldClientKey = oldClientKey; }
public String getNewRootKey() { return newRootKey; }
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
public String getNewBlockchainKey() { return newBlockchainKey; }
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
public String getNewClientKey() { return newClientKey; }
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
public Integer getForkFromBlock() { return forkFromBlock; }
public void setForkFromBlock(Integer forkFromBlock) { this.forkFromBlock = forkFromBlock; }
public String getForkFromHash() { return forkFromHash; }
public void setForkFromHash(String forkFromHash) { this.forkFromHash = forkFromHash; }
public Integer getSourceTipBlock() { return sourceTipBlock; }
public void setSourceTipBlock(Integer sourceTipBlock) { this.sourceTipBlock = sourceTipBlock; }
public String getSourceTipHash() { return sourceTipHash; }
public void setSourceTipHash(String sourceTipHash) { this.sourceTipHash = sourceTipHash; }
public Short getReasonCode() { return reasonCode; }
public void setReasonCode(Short reasonCode) { this.reasonCode = reasonCode; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
public Integer getProgressCurrent() { return progressCurrent; }
public void setProgressCurrent(Integer progressCurrent) { this.progressCurrent = progressCurrent; }
public Integer getProgressTotal() { return progressTotal; }
public void setProgressTotal(Integer progressTotal) { this.progressTotal = progressTotal; }
public String getPdaRotationSignature() { return pdaRotationSignature; }
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
public String getWalletMigrationStatus() { return walletMigrationStatus; }
public void setWalletMigrationStatus(String walletMigrationStatus) { this.walletMigrationStatus = walletMigrationStatus; }
public String getMessageMigrationStatus() { return messageMigrationStatus; }
public void setMessageMigrationStatus(String messageMigrationStatus) { this.messageMigrationStatus = messageMigrationStatus; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public Integer getRetryCount() { return retryCount; }
public void setRetryCount(Integer retryCount) { this.retryCount = retryCount; }
public Long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(Long createdAtMs) { this.createdAtMs = createdAtMs; }
public Long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(Long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
}
@@ -0,0 +1,7 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Запрос текущего состояния ротации авторизованного пользователя. */
public final class Net_KeyRotationStatus_Request extends Net_Request {
}
@@ -216,10 +216,188 @@ public final class ShineUsersCodec {
int formatMajor = raw[5] & 0xFF; int formatMajor = raw[5] & 0xFF;
int formatMinor = raw[6] & 0xFF; int formatMinor = raw[6] & 0xFF;
if (formatMajor != 1 || formatMinor != 2) { if (formatMajor != 1) {
throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor); throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor);
} }
return parseUserPdaAccountV12(pdaAddress, slot, rawDataBase64, lastTxSignature, raw); if (formatMinor == 2) {
return parseUserPdaAccountV12(pdaAddress, slot, rawDataBase64, lastTxSignature, raw);
}
if (formatMinor == 0) {
// Временная read-only совместимость: legacy PDA 1.0 снова читается
// server-sync только для bootstrap старых server-записей. Новые записи
// и update по-прежнему работают только с текущим форматом 1.2.
return parseUserPdaAccountV10(pdaAddress, slot, rawDataBase64, lastTxSignature, raw);
}
throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor);
}
private static UserPdaSnapshot parseUserPdaAccountV10(
String pdaAddress,
long slot,
String rawDataBase64,
String lastTxSignature,
byte[] raw
) {
int recordLen = u16le(raw, 7);
if (recordLen < 9 + 64 || recordLen > raw.length) {
throw new IllegalArgumentException("Invalid PDA 1.0 record length");
}
byte[] useful = new byte[recordLen];
System.arraycopy(raw, 0, useful, 0, recordLen);
Reader reader = new Reader(useful);
reader.skip(9);
long createdAtMs = reader.readU64();
long updatedAtMs = reader.readU64();
int recordNumber = (int) reader.readU32();
String prevHash = toHex(reader.readFixed(32));
String login = reader.readStringU8();
int blocksCount = reader.readU8();
String recoveryKey = null;
String rootKey = null;
String clientKey = null;
String blockchainName = null;
String blockchainKey = null;
long paidLimitBytes = 0L;
long usedBytes = 0L;
int lastBlockNumber = 0;
String lastBlockHash = "";
String lastBlockSignature = "";
String arweaveTxId = "";
String archiveHeadTxId = "";
String archiveHeadHash = "";
boolean isServer = false;
int addressFormatType = 0;
int addressFormatVersion = 0;
String serverAddress = "";
List<String> syncServers = new ArrayList<>();
List<String> accessServers = new ArrayList<>();
int sessionsMode = 1;
List<UserSessionSnapshot> sessions = new ArrayList<>();
int trustedCount = 0;
boolean seenRecovery = false;
boolean seenRoot = false;
boolean seenClient = false;
boolean seenBlockchain = false;
for (int i = 0; i < blocksCount; i++) {
int blockType = reader.readU8();
int blockVersion = reader.readU8();
if (blockVersion != BLOCK_VERSION_0) {
throw new IllegalArgumentException("Unsupported PDA 1.0 block version: type=" + blockType + " version=" + blockVersion);
}
switch (blockType) {
case BLOCK_TYPE_RECOVERY_KEY -> {
if (seenRecovery) throw new IllegalArgumentException("Duplicate RecoveryKeyBlock in PDA 1.0");
recoveryKey = Base58Util.encode(reader.readFixed(32));
seenRecovery = true;
}
case BLOCK_TYPE_ROOT_KEY -> {
if (seenRoot) throw new IllegalArgumentException("Duplicate RootKeyBlock in PDA 1.0");
rootKey = Base58Util.encode(reader.readFixed(32));
seenRoot = true;
}
case BLOCK_TYPE_CLIENT_KEY -> {
if (seenClient) throw new IllegalArgumentException("Duplicate ClientKeyBlock in PDA 1.0");
clientKey = Base58Util.encode(reader.readFixed(32));
seenClient = true;
}
case BLOCK_TYPE_BLOCKCHAIN_REGISTRY -> {
if (seenBlockchain) throw new IllegalArgumentException("Duplicate BlockchainRegistryBlock in PDA 1.0");
int count = reader.readU8();
if (count < 1) throw new IllegalArgumentException("Empty BlockchainRegistryBlock in PDA 1.0");
for (int j = 0; j < count; j++) {
int blockchainType = reader.readU8();
String currentName = reader.readStringU8();
String currentKey = Base58Util.encode(reader.readFixed(32));
long currentPaidLimit = reader.readU64();
long currentUsedBytes = reader.readU64();
long currentLastBlockNumber = reader.readU32();
String currentLastBlockHash = toHex(reader.readFixed(32));
String currentLastBlockSignature = Base58Util.encode(reader.readFixed(64));
int arweavePresent = reader.readU8();
String currentArweaveTxId = arweavePresent == 1 ? reader.readStringU8() : "";
if (arweavePresent != 0 && arweavePresent != 1) {
throw new IllegalArgumentException("Invalid PDA 1.0 arweave_present");
}
// Runtime SHiNE использует основной пользовательский blockchain (type=1).
// Если legacy запись содержит несколько chain records, берём первый MAIN_USER.
if (blockchainName == null && blockchainType == BLOCKCHAIN_TYPE_MAIN_USER) {
blockchainName = currentName;
blockchainKey = currentKey;
paidLimitBytes = currentPaidLimit;
usedBytes = currentUsedBytes;
lastBlockNumber = Math.toIntExact(currentLastBlockNumber);
lastBlockHash = currentLastBlockHash;
lastBlockSignature = currentLastBlockSignature;
arweaveTxId = currentArweaveTxId;
}
}
seenBlockchain = true;
}
case BLOCK_TYPE_SERVER_PROFILE -> {
int serverFlag = reader.readU8();
if (serverFlag != 0 && serverFlag != 1) {
throw new IllegalArgumentException("Invalid PDA 1.0 is_server");
}
isServer = serverFlag == 1;
if (isServer) {
addressFormatType = reader.readU8();
addressFormatVersion = reader.readU8();
serverAddress = reader.readStringU8();
int count = reader.readU8();
for (int j = 0; j < count; j++) syncServers.add(reader.readStringU8());
}
}
case BLOCK_TYPE_ACCESS_SERVERS -> {
int count = reader.readU8();
for (int j = 0; j < count; j++) accessServers.add(reader.readStringU8());
}
case BLOCK_TYPE_SESSIONS -> {
sessionsMode = reader.readU8();
int count = reader.readU8();
for (int j = 0; j < count; j++) {
int sessionType = reader.readU8();
int sessionVersion = reader.readU8();
String sessionName = reader.readStringU8();
String sessionPubKey = Base58Util.encode(reader.readFixed(32));
sessions.add(new UserSessionSnapshot(sessionType, sessionVersion, sessionName, sessionPubKey));
}
}
case BLOCK_TYPE_TRUSTED_STATE -> trustedCount = reader.readU8();
case BLOCK_TYPE_ARCHIVE_HEAD -> {
archiveHeadTxId = Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32));
archiveHeadHash = toHex(reader.readFixed(32));
}
default -> throw new IllegalArgumentException("Unsupported PDA 1.0 block type: " + blockType);
}
}
String signature = Base58Util.encode(reader.readFixed(64));
if (reader.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail inside PDA 1.0 record");
}
if (!seenRecovery || !seenRoot || !seenClient || !seenBlockchain
|| recoveryKey == null || rootKey == null || clientKey == null
|| blockchainName == null || blockchainKey == null) {
throw new IllegalArgumentException("PDA 1.0 misses required blocks");
}
return new UserPdaSnapshot(
pdaAddress, login, recordNumber, slot, lastTxSignature,
recoveryKey, rootKey, clientKey, blockchainName, blockchainKey, paidLimitBytes,
usedBytes, lastBlockNumber, lastBlockHash, lastBlockSignature, arweaveTxId,
archiveHeadTxId, archiveHeadHash,
isServer, addressFormatType, addressFormatVersion, serverAddress,
List.copyOf(syncServers), List.copyOf(accessServers), sessionsMode, List.copyOf(sessions), trustedCount,
createdAtMs, updatedAtMs, prevHash, signature, rawDataBase64,
List.of(new BlockchainForkSnapshot(0, blockchainKey, createdAtMs, paidLimitBytes))
);
} }
private static ParsedInstruction parseV12PdaMutationInstruction( private static ParsedInstruction parseV12PdaMutationInstruction(
@@ -1,5 +1,6 @@
package sync.storage.postgres; package sync.storage.postgres;
import sync.util.Base58Util;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -272,6 +273,11 @@ public final class PostgresStorageRepository
snapshots snapshots
); );
reconcileConfirmedKeyRotations(
connection,
snapshots
);
upsertSyncState( upsertSyncState(
connection, connection,
newState newState
@@ -305,6 +311,11 @@ public final class PostgresStorageRepository
snapshots snapshots
); );
reconcileConfirmedKeyRotations(
connection,
snapshots
);
upsertSyncState( upsertSyncState(
connection, connection,
newState newState
@@ -536,6 +547,90 @@ public final class PostgresStorageRepository
} }
} }
/**
* Solana является источником истины для момента завершения ротации PDA.
* После upsert current-снимка сверяем все три новых public key и новый blockchainName.
* Совпадение переводит локальную машину ROTATING_PDA -> PDA_ROTATED в той же DB-транзакции.
*/
private void reconcileConfirmedKeyRotations(
Connection connection,
List<ShineUsersCodec.UserPdaSnapshot> snapshots
) throws Exception {
if (snapshots == null || snapshots.isEmpty()) return;
String select = """
SELECT id, new_root_key, new_blockchain_key, new_client_key, candidate_blockchain_name
FROM key_rotation_sessions
WHERE login = ? AND status IN ('CHAIN_READY', 'ROTATING_PDA')
ORDER BY id DESC
LIMIT 1
FOR UPDATE
""";
for (ShineUsersCodec.UserPdaSnapshot snapshot : snapshots) {
if (snapshot == null || snapshot.login() == null || snapshot.login().isBlank()) continue;
try (PreparedStatement ps = connection.prepareStatement(select)) {
ps.setString(1, snapshot.login());
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) continue;
long rotationId = rs.getLong("id");
String expectedRoot = rs.getString("new_root_key");
String expectedBlockchain = rs.getString("new_blockchain_key");
String expectedClient = rs.getString("new_client_key");
String expectedBlockchainName = rs.getString("candidate_blockchain_name");
String actualRoot = keyToBase64(snapshot.rootKey());
String actualBlockchain = keyToBase64(snapshot.blockchainKey());
String actualClient = keyToBase64(snapshot.clientKey());
if (!expectedRoot.equals(actualRoot)
|| !expectedBlockchain.equals(actualBlockchain)
|| !expectedClient.equals(actualClient)
|| !expectedBlockchainName.equals(snapshot.blockchainName())) {
continue;
}
long now = System.currentTimeMillis();
try (PreparedStatement update = connection.prepareStatement("""
UPDATE key_rotation_sessions
SET status = 'PDA_ROTATED',
pda_rotation_signature = COALESCE(pda_rotation_signature, ?),
updated_at_ms = ?,
last_error = NULL,
last_error_at_ms = NULL
WHERE id = ? AND status IN ('CHAIN_READY', 'ROTATING_PDA')
""")) {
update.setString(1, snapshot.lastTxSignature() == null || snapshot.lastTxSignature().isBlank() ? null : snapshot.lastTxSignature());
update.setLong(2, now);
update.setLong(3, rotationId);
if (update.executeUpdate() != 1) continue;
}
try (PreparedStatement update = connection.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = 'PDA_ROTATED', rotation_session_id = ?
WHERE login = ? AND rotation_session_id = ? AND rotation_status IN ('CHAIN_READY', 'ROTATING_PDA')
""")) {
update.setLong(1, rotationId);
update.setString(2, snapshot.login());
update.setLong(3, rotationId);
if (update.executeUpdate() != 1) {
throw new SQLException("Rotation session is not attached to synced login=" + snapshot.login());
}
}
}
}
}
}
private static String keyToBase64(String base58) {
if (base58 == null || base58.isBlank()) return "";
byte[] decoded = Base58Util.decode(base58);
if (decoded.length != 32) return "";
return java.util.Base64.getEncoder().encodeToString(decoded);
}
private void bindSnapshot( private void bindSnapshot(
PreparedStatement statement, PreparedStatement statement,
ShineUsersCodec.UserPdaSnapshot snapshot, ShineUsersCodec.UserPdaSnapshot snapshot,
@@ -0,0 +1,119 @@
package sync.codec;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.*;
class ShineUsersCodecLegacyV10Test {
@Test
void readsLegacyV10ServerPdaForBootstrap() {
byte[] raw = buildLegacyServerPda();
ShineUsersCodec.UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(
"legacy-pda", 123L, Base64.getEncoder().encodeToString(raw), "legacy-tx"
);
assertEquals("legacy_server", snapshot.login());
assertEquals("legacy_server-001", snapshot.blockchainName());
assertEquals(100_000L, snapshot.paidLimitBytes());
assertEquals(12_345L, snapshot.usedBytes());
assertEquals(7, snapshot.lastBlockNumber());
assertTrue(snapshot.isServer());
assertEquals(1, snapshot.addressFormatType());
assertEquals(0, snapshot.addressFormatVersion());
assertEquals("wss://legacy.example/ws", snapshot.serverAddress());
assertEquals(java.util.List.of("sync-old"), snapshot.syncServers());
assertEquals(java.util.List.of("access-old"), snapshot.accessServers());
assertEquals(1, snapshot.blockchainForks().size());
assertEquals(snapshot.blockchainKey(), snapshot.blockchainForks().get(0).blockchainKey());
}
private static byte[] buildLegacyServerPda() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
bytes(out, "SHiNE".getBytes(StandardCharsets.UTF_8));
u8(out, 1);
u8(out, 0);
u16(out, 0); // record_len patch later
u64(out, 1_700_000_000_000L);
u64(out, 1_700_000_001_000L);
u32(out, 3);
bytes(out, repeat(0x11, 32));
str(out, "legacy_server");
u8(out, 7); // recovery, root, client, blockchain, server, access, trusted
u8(out, 0); u8(out, 0); bytes(out, repeat(0x21, 32));
u8(out, 1); u8(out, 0); bytes(out, repeat(0x22, 32));
u8(out, 2); u8(out, 0); bytes(out, repeat(0x23, 32));
u8(out, 3); u8(out, 0);
u8(out, 1);
u8(out, 1);
str(out, "legacy_server-001");
bytes(out, repeat(0x24, 32));
u64(out, 100_000L);
u64(out, 12_345L);
u32(out, 7);
bytes(out, repeat(0x25, 32));
bytes(out, repeat(0x26, 64));
u8(out, 1);
str(out, "legacy-arweave-tx");
u8(out, 30); u8(out, 0);
u8(out, 1);
u8(out, 1);
u8(out, 0);
str(out, "wss://legacy.example/ws");
u8(out, 1);
str(out, "sync-old");
u8(out, 40); u8(out, 0);
u8(out, 1);
str(out, "access-old");
u8(out, 70); u8(out, 0); u8(out, 2);
bytes(out, repeat(0x27, 64));
byte[] record = out.toByteArray();
record[7] = (byte) (record.length & 0xff);
record[8] = (byte) ((record.length >>> 8) & 0xff);
return record;
}
private static byte[] repeat(int value, int count) {
byte[] bytes = new byte[count];
java.util.Arrays.fill(bytes, (byte) value);
return bytes;
}
private static void str(ByteArrayOutputStream out, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
u8(out, bytes.length);
bytes(out, bytes);
}
private static void bytes(ByteArrayOutputStream out, byte[] value) {
out.writeBytes(value);
}
private static void u8(ByteArrayOutputStream out, long value) {
out.write((int) value & 0xff);
}
private static void u16(ByteArrayOutputStream out, long value) {
u8(out, value);
u8(out, value >>> 8);
}
private static void u32(ByteArrayOutputStream out, long value) {
for (int i = 0; i < 4; i++) u8(out, value >>> (8 * i));
}
private static void u64(ByteArrayOutputStream out, long value) {
for (int i = 0; i < 8; i++) u8(out, value >>> (8 * i));
}
}
@@ -0,0 +1,35 @@
package server.keyrotation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Небольшой background worker, который продолжает rebuild даже если UI был закрыт. */
public final class KeyRotationRebuildScheduler {
private static final Logger log = LoggerFactory.getLogger(KeyRotationRebuildScheduler.class);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static final KeyRotationRebuildService SERVICE = new KeyRotationRebuildService();
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override public Thread newThread(Runnable r) {
Thread t = new Thread(r, "key-rotation-rebuild");
t.setDaemon(true);
return t;
}
});
private KeyRotationRebuildScheduler() { }
public static void startOrLog() {
if (!STARTED.compareAndSet(false, true)) return;
EXECUTOR.scheduleWithFixedDelay(() -> {
try { SERVICE.runReady(4); }
catch (Exception e) { log.warn("Key rotation rebuild cycle failed", e); }
}, 1, 2, TimeUnit.SECONDS);
log.info("Key rotation rebuild scheduler started");
}
}
@@ -0,0 +1,166 @@
package server.keyrotation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import server.sync.BlockchainResyncGuard;
import shine.db.dao.BlockchainResyncCleanupDAO;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.KeyRotationCandidateBlocksDAO;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.dao.SolanaUserPdaCurrentDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import shine.db.entities.SolanaUserPdaCurrentEntry;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;
/**
* Автоматически materialize-ит candidate fork после того, как Solana sync подтвердил новое PDA.
* Candidate-блоки уже опубликованы в Arweave; здесь они только становятся единственной рабочей
* цепочкой PostgreSQL и повторно проходят обычный AddBlock validation/projection path.
*/
public final class KeyRotationRebuildService {
private static final Logger log = LoggerFactory.getLogger(KeyRotationRebuildService.class);
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
private final BlockchainResyncCleanupDAO cleanup = BlockchainResyncCleanupDAO.getInstance();
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance();
private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler();
public int runReady(int limit) {
int processed = 0;
try {
for (KeyRotationSessionEntry session : rotations.listByStatus(KeyRotationStatus.PDA_ROTATED, limit)) {
if (tryRebuild(session)) processed++;
}
for (KeyRotationSessionEntry session : rotations.listByStatus(KeyRotationStatus.REBUILDING_SERVER, Math.max(1, limit - processed))) {
if (tryRebuild(session)) processed++;
}
} catch (Exception e) {
log.warn("Key rotation rebuild scan failed", e);
}
return processed;
}
public boolean tryRebuild(KeyRotationSessionEntry session) {
if (session == null) return false;
try {
KeyRotationSessionEntry current = rotations.getById(session.getId());
if (current == null) return false;
if (current.getStatus() == KeyRotationStatus.PDA_ROTATED) {
current = rotations.transition(current.getId(), KeyRotationStatus.PDA_ROTATED, KeyRotationStatus.REBUILDING_SERVER);
}
if (current.getStatus() != KeyRotationStatus.REBUILDING_SERVER) return false;
rebuild(current);
rotations.clearError(current.getId());
rotations.transition(current.getId(), KeyRotationStatus.REBUILDING_SERVER, KeyRotationStatus.WALLET_MIGRATION);
log.info("Key rotation rebuild complete: login={} {} -> {}", current.getLogin(), current.getSourceBlockchainName(), current.getCandidateBlockchainName());
return true;
} catch (Exception e) {
try { rotations.recordError(session.getId(), "REBUILDING_SERVER: " + safeMessage(e)); } catch (Exception ignored) { }
log.warn("Key rotation rebuild failed: id={} login={}", session.getId(), session.getLogin(), e);
return false;
}
}
private void rebuild(KeyRotationSessionEntry session) throws Exception {
verifyCurrentPda(session);
List<KeyRotationCandidateBlockEntry> candidateBlocks;
try (var c = shine.db.DbController.getInstance().getConnection()) {
candidateBlocks = candidates.listBySession(c, session.getId());
}
if (candidateBlocks.size() != session.getProgressTotal()) {
throw new IllegalStateException("candidate count mismatch: " + candidateBlocks.size() + "/" + session.getProgressTotal());
}
for (KeyRotationCandidateBlockEntry block : candidateBlocks) {
if (block.isArweavePublishPending()) {
throw new IllegalStateException("candidate block is not published: #" + block.getBlockNumber());
}
}
String source = session.getSourceBlockchainName();
String target = session.getCandidateBlockchainName();
ReentrantLock first = BlockchainLocks.lockFor(source.compareTo(target) <= 0 ? source : target);
ReentrantLock second = BlockchainLocks.lockFor(source.compareTo(target) <= 0 ? target : source);
first.lock();
second.lock();
boolean sourceGuard = false;
boolean targetGuard = false;
try {
sourceGuard = BlockchainResyncGuard.tryBegin(source);
targetGuard = BlockchainResyncGuard.tryBegin(target);
if (!sourceGuard || !targetGuard) {
throw new IllegalStateException("another resync/rebuild is active");
}
// Повторный запуск безопасен: сначала убираем любую частично materialized candidate-цепочку.
if (states.getByBlockchainName(target) != null) {
cleanup.cleanupBlockchainForFullResync(target);
}
if (states.getByBlockchainName(source) != null) {
cleanup.cleanupBlockchainForFullResync(source);
}
createCandidateState(session);
BlockchainResyncGuard.withBypass(target, () -> {
for (KeyRotationCandidateBlockEntry candidate : candidateBlocks) {
Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(target, candidate.getBlockBytes());
if (!result.ok()) {
throw new IllegalStateException("candidate replay rejected at #" + candidate.getBlockNumber() + ": " + result.reasonCode());
}
}
});
// Чужие подписанные ссылки сохраняют логическую identity login+number+hash.
// Здесь меняется только их локальный cache физического fork.
cleanup.refreshLogicalTargetBlockchainCache(session.getLogin(), source, target);
} finally {
if (targetGuard) BlockchainResyncGuard.end(target);
if (sourceGuard) BlockchainResyncGuard.end(source);
second.unlock();
first.unlock();
}
}
private void verifyCurrentPda(KeyRotationSessionEntry session) throws Exception {
SolanaUserPdaCurrentEntry user = users.getByLogin(session.getLogin());
if (user == null) throw new IllegalStateException("current PDA not found");
if (!session.getCandidateBlockchainName().equals(user.getBlockchainName())) {
throw new IllegalStateException("current PDA blockchainName mismatch");
}
if (!session.getNewBlockchainKey().equals(user.getBlockchainKey())) {
throw new IllegalStateException("current PDA blockchain key mismatch");
}
}
private void createCandidateState(KeyRotationSessionEntry session) throws Exception {
SolanaUserPdaCurrentEntry user = users.getByLogin(session.getLogin());
byte[] key = Base64.getDecoder().decode(session.getNewBlockchainKey());
if (key.length != 32) throw new IllegalStateException("new blockchain key length != 32");
BlockchainStateEntry state = new BlockchainStateEntry();
state.setBlockchainName(session.getCandidateBlockchainName());
state.setLogin(session.getLogin());
state.setBlockchainKey(session.getNewBlockchainKey());
state.setSizeLimit(user != null && user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L);
state.setFileSizeBytes(0L);
state.setLastBlockNumber(-1);
state.setLastBlockHash(null);
state.setUpdatedAtMs(System.currentTimeMillis());
states.insertIfMissing(state);
}
private static String safeMessage(Throwable t) {
String value = t == null ? "unknown" : String.valueOf(t.getMessage());
if (value.length() > 1000) value = value.substring(0, 1000);
return value;
}
}
@@ -1,5 +1,7 @@
package server.ws; package server.ws;
import server.keyrotation.KeyRotationRebuildScheduler;
import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
@@ -61,6 +63,7 @@ public final class WsServer {
// ANS-104: publish locally-created signed user blocks and discover blocks published by other servers. // ANS-104: publish locally-created signed user blocks and discover blocks published by other servers.
ArweaveBlockPublisherScheduler.startOrLog(); ArweaveBlockPublisherScheduler.startOrLog();
ArweaveBlockSyncScheduler.startOrLog(); ArweaveBlockSyncScheduler.startOrLog();
KeyRotationRebuildScheduler.startOrLog();
// ============================================================ // ============================================================
// 2) Запуск Jetty WS // 2) Запуск Jetty WS
@@ -4,7 +4,8 @@ import blockchain.MsgSubType;
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.TextBody; import blockchain.body.TextLineBody;
import blockchain.body.TextReplyBody;
import shine.db.DbController; import shine.db.DbController;
import test.it.blockchain.AddBlockSender; import test.it.blockchain.AddBlockSender;
import test.it.blockchain.ChainState; import test.it.blockchain.ChainState;
@@ -27,7 +28,7 @@ import static org.junit.jupiter.api.Assertions.*;
* CONNECTION (type=3): * CONNECTION (type=3):
* - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber) * - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber)
* - всегда имеет target: * - всегда имеет target:
* toBlockchainName + toBlockGlobalNumber + toBlockHash32 * toLogin + toBlockGlobalNumber + toBlockHash32
* *
* Правило target для связей/подписок: * Правило target для связей/подписок:
* - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0) * - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0)
@@ -88,12 +89,12 @@ public class IT_03_AddBlock_NoAuth {
// POST в канал "0" // POST в канал "0"
{ {
var ln = st1.nextTextLineByRoot(root0); var ln = st1.nextTextLineByRoot(root0);
sender1.send(new TextBody( sender1.send(new TextLineBody(
MsgSubType.TEXT_POST,
root0, root0,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"U1: story/post in channel 0", MsgSubType.TEXT_POST,
null, null, null null, null, null,
"U1: story/post in channel 0"
), t); ), t);
} }
@@ -148,12 +149,12 @@ public class IT_03_AddBlock_NoAuth {
byte[] newsPost0Hash; byte[] newsPost0Hash;
{ {
var ln = st1.nextTextLineByRoot(newsRootBlock); var ln = st1.nextTextLineByRoot(newsRootBlock);
sender1.send(new TextBody( sender1.send(new TextLineBody(
MsgSubType.TEXT_POST,
newsRootBlock, newsRootBlock,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"U1: News post #0", MsgSubType.TEXT_POST,
null, null, null null, null, null,
"U1: News post #0"
), t); ), t);
newsPost0Block = st1.lastBlockNumber(); newsPost0Block = st1.lastBlockNumber();
@@ -164,26 +165,26 @@ public class IT_03_AddBlock_NoAuth {
// POST #1 в канал "News" // POST #1 в канал "News"
{ {
var ln = st1.nextTextLineByRoot(newsRootBlock); var ln = st1.nextTextLineByRoot(newsRootBlock);
sender1.send(new TextBody( sender1.send(new TextLineBody(
MsgSubType.TEXT_POST,
newsRootBlock, newsRootBlock,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"U1: News post #1", MsgSubType.TEXT_POST,
null, null, null null, null, null,
"U1: News post #1"
), t); ), t);
} }
// EDIT_POST (в линии канала) -> target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName) // EDIT_POST -> target на ОРИГИНАЛЬНЫЙ POST по login + number + hash
{ {
var ln = st1.nextTextLineByRoot(newsRootBlock); var ln = st1.nextTextLineByRoot(newsRootBlock);
sender1.send(new TextBody( sender1.send(new TextLineBody(
MsgSubType.TEXT_EDIT_POST,
newsRootBlock, newsRootBlock,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"U1: News post #0 (EDIT)", MsgSubType.TEXT_EDIT_POST,
null,
newsPost0Block, newsPost0Block,
newsPost0Hash newsPost0Hash,
u1,
"U1: News post #0 (EDIT)"
), t); ), t);
} }
@@ -206,17 +207,17 @@ public class IT_03_AddBlock_NoAuth {
// 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2) // 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2)
sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW, sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW,
bch2, u2HeaderBlock, u2HeaderHash, u2, u2HeaderBlock, u2HeaderHash,
"U1 follows U2 (target=U2 HEADER)", t); "U1 follows U2 (target=U2 HEADER)", t);
// 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1) // 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1)
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW, sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
bch1, newsRootBlock, newsRootHash, u1, newsRootBlock, newsRootHash,
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t); "U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
// 3) U2 подписался на второй канал U1 "Updates" // 3) U2 подписался на второй канал U1 "Updates"
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW, sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
bch1, updatesRootBlock, updatesRootHash, u1, updatesRootBlock, updatesRootHash,
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t); "U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
assertEquals(2, countConnectionsByOwner(u2, u1), assertEquals(2, countConnectionsByOwner(u2, u1),
@@ -230,30 +231,31 @@ public class IT_03_AddBlock_NoAuth {
// 4) FRIEND взаимно (на HEADER) // 4) FRIEND взаимно (на HEADER)
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND, sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch2, u2HeaderBlock, u2HeaderHash, u2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: FRIEND", t); "U1 -> U2: FRIEND", t);
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND, sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch1, u1HeaderBlock, u1HeaderHash, u1, u1HeaderBlock, u1HeaderHash,
"U2 -> U1: FRIEND", t); "U2 -> U1: FRIEND", t);
// 5) CONTACT несколько // 5) CONTACT несколько
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT, sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
bch2, u2HeaderBlock, u2HeaderHash, u2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: CONTACT", t); "U1 -> U2: CONTACT", t);
sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT, sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT,
bch1, u1HeaderBlock, u1HeaderHash, u1, u1HeaderBlock, u1HeaderHash,
"U2 -> U1: CONTACT", t); "U2 -> U1: CONTACT", t);
// ========================= // =========================
// USER2 REPLY (ответ в чужой канал) // USER2 REPLY (ответ в чужой канал)
// ========================= // =========================
{ {
sender2.send(TextBody.newReply( sender2.send(new TextReplyBody(
bch1, MsgSubType.TEXT_REPLY,
newsPost0Block, newsPost0Block,
newsPost0Hash, newsPost0Hash,
u1,
"U2: reply to U1 News post #0 (cross-chain)" "U2: reply to U1 News post #0 (cross-chain)"
), t); ), t);
} }
@@ -273,12 +275,12 @@ public class IT_03_AddBlock_NoAuth {
// U1 -> U3: CONTACT // U1 -> U3: CONTACT
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT, sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
bch3, u3HeaderBlock, u3HeaderHash, u3, u3HeaderBlock, u3HeaderHash,
"U1 -> U3: CONTACT", t); "U1 -> U3: CONTACT", t);
// 6) U2 отписывается только от News // 6) U2 отписывается только от News
sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW, sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW,
bch1, newsRootBlock, newsRootHash, u1, newsRootBlock, newsRootHash,
"U2 unfollows U1 channel 'News'", t); "U2 unfollows U1 channel 'News'", t);
assertEquals(1, countConnectionsByOwner(u2, u1), assertEquals(1, countConnectionsByOwner(u2, u1),
@@ -292,7 +294,7 @@ public class IT_03_AddBlock_NoAuth {
// 7) U1 убирает U2 из контактов (UNCONTACT) // 7) U1 убирает U2 из контактов (UNCONTACT)
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT, sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
bch2, u2HeaderBlock, u2HeaderHash, u2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: UNCONTACT", t); "U1 -> U2: UNCONTACT", t);
r.ok("IT_03 сценарий блоков + connections выполнен"); r.ok("IT_03 сценарий блоков + connections выполнен");
@@ -313,7 +315,7 @@ public class IT_03_AddBlock_NoAuth {
private static void sendConnection(AddBlockSender sender, private static void sendConnection(AddBlockSender sender,
ChainState st, ChainState st,
short subType, short subType,
String toBlockchainName, String toLogin,
int toBlockNumber, int toBlockNumber,
byte[] toBlockHash32, byte[] toBlockHash32,
String logNote, String logNote,
@@ -321,7 +323,7 @@ public class IT_03_AddBlock_NoAuth {
if (TestConfig.DEBUG()) { if (TestConfig.DEBUG()) {
TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF) TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF)
+ " to=" + toBlockchainName + " to=" + toLogin
+ " targetBlock=" + toBlockNumber + " targetBlock=" + toBlockNumber
+ " note=" + logNote); + " note=" + logNote);
} }
@@ -330,14 +332,14 @@ public class IT_03_AddBlock_NoAuth {
// КОНСТРУКТОР ИЗ ТВОЕГО КОДА: // КОНСТРУКТОР ИЗ ТВОЕГО КОДА:
// ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, // ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber,
// short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) // short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32)
sender.send(new ConnectionBody( sender.send(new ConnectionBody(
0, // lineCode для connection линии 0, // lineCode для connection линии
ln.prevLineNumber, ln.prevLineNumber,
ln.prevLineHash32, ln.prevLineHash32,
ln.thisLineNumber, ln.thisLineNumber,
subType, subType,
toBlockchainName, toLogin,
toBlockNumber, toBlockNumber,
toBlockHash32 toBlockHash32
), timeout); ), timeout);
@@ -151,7 +151,7 @@ public final class SeedDataPopulationHelper {
line.prevLineHash32, line.prevLineHash32,
line.thisLineNumber, line.thisLineNumber,
relationSubType, relationSubType,
bch(to), to,
0, 0,
targetHeaderHash targetHeaderHash
), timeout); ), timeout);
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.14.1 client.version=1.14.0
server.version=1.12.0 server.version=1.12.0
+10
View File
@@ -113,3 +113,13 @@ cp /path/to/SHiNE-product/application.properties ./application.properties
После обновления `shine_users` серверный модуль `shine-server-solana-users-sync` должен обновляться вместе с программой: текущий codec принимает только PDA 1.2. Legacy PDA 1.0 не мигрируются; тестовые legacy-записи можно закрыть временной инструкцией `close_legacy_pda`. После обновления `shine_users` серверный модуль `shine-server-solana-users-sync` должен обновляться вместе с программой: текущий codec принимает только PDA 1.2. Legacy PDA 1.0 не мигрируются; тестовые legacy-записи можно закрыть временной инструкцией `close_legacy_pda`.
Миграция PostgreSQL v25 добавляет `blockchain_forks_json`. Старые compatibility-колонки продолжают содержать активный (последний) fork, а полный список ключей fork сохраняется отдельно и используется для поиска владельца Arweave-записей по любому историческому blockchain key. Миграция PostgreSQL v25 добавляет `blockchain_forks_json`. Старые compatibility-колонки продолжают содержать активный (последний) fork, а полный список ключей fork сохраняется отдельно и используется для поиска владельца Arweave-записей по любому историческому blockchain key.
## PostgreSQL migration v26: key rotation runtime state
Migration v26 добавляет server-local поля `rotation_status` / `rotation_session_id` в `solana_user_pda_current` и таблицу `key_rotation_sessions`.
Это локальное состояние длительной смены ключей, а не часть Solana PDA. Solana users sync продолжает обновлять только PDA-поля и не должен затирать rotation-state. После обновления сервера миграция применяется стандартным `DatabaseInitializer` автоматически.
## Миграции key rotation
При обновлении сервера DatabaseInitializer последовательно применяет migration v26 (state machine смены ключей) и v27 (отдельные candidate-блоки будущего fork). Ручного создания таблиц не требуется. Candidate-блоки не входят в текущую таблицу `blocks` до финального переключения fork.
@@ -43,6 +43,8 @@
- `bad_block_number` — нарушена последовательность; - `bad_block_number` — нарушена последовательность;
- `bad_prev_hash` — нарушена SHiNE hash chain; - `bad_prev_hash` — нарушена SHiNE hash chain;
- `bad_block_bytes` — DataItem/Frame не парсится. - `bad_block_bytes` — DataItem/Frame не парсится.
- `key_rotation_in_progress` (HTTP/status `423`) — для пользователя уже запущена смена ключей; обычный `AddBlock` временно запрещён до завершения/отмены ротации;
- `key_rotation_check_failed` — сервер не смог проверить локальный rotation status и отклонил запись fail-closed.
## Storage/publish ## Storage/publish
+8
View File
@@ -31,6 +31,14 @@
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии | | `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн | | `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна | | `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна |
| `KeyRotationStart` | `19_Key_Rotation_API.md` | начать смену ключей сразу в состоянии `COPYING_CHAIN` |
| `KeyRotationStatus` | `19_Key_Rotation_API.md` | получить текущий этап и прогресс смены ключей |
| `KeyRotationAddBlock` | `19_Key_Rotation_API.md` | добавить подписанный новым blockchain key candidate-блок будущего fork |
| `KeyRotationFinishChain` | `19_Key_Rotation_API.md` | проверить полную публикацию candidate-chain и перевести её в `CHAIN_READY` |
| `KeyRotationRotatePda` | `19_Key_Rotation_API.md` | зафиксировать отправленную Solana-ротацию PDA и ждать подтверждения sync |
| `KeyRotationContinue` | `19_Key_Rotation_API.md` | продолжить интерактивный post-PDA этап; сейчас wallet/DM отмечаются как `NOT_IMPLEMENTED` и пропускаются |
| `KeyRotationAbort` | `19_Key_Rotation_API.md` | прервать ротацию до отправки Solana PDA-ротации |
| `GetMyBlockchain` | `20_Get_My_Blockchain_API.md` | постранично читать текущую активную собственную цепочку для аудита и выбора fork point |
| `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки | | `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки |
| `Ping` | `05_Technical_Requests_API.md` | keep-alive | | `Ping` | `05_Technical_Requests_API.md` | keep-alive |
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере | | `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
+320
View File
@@ -0,0 +1,320 @@
# Key Rotation API
Этот раздел описывает публичные JSON/WebSocket операции мастера смены ключей пользователя.
Публично доступны:
- `KeyRotationStart`
- `KeyRotationStatus`
- `KeyRotationAddBlock`
- `KeyRotationFinishChain`
- `KeyRotationRotatePda`
- `KeyRotationContinue`
- `KeyRotationAbort`
После `PDA_ROTATED` отдельный фоновый worker автоматически переводит ротацию в `REBUILDING_SERVER`, делает новую candidate-chain единственной рабочей цепочкой PostgreSQL и затем переводит процесс в `WALLET_MIGRATION`.
## Общие правила
- операции доступны только авторизованному LOCAL-пользователю своего access server;
- login берётся из авторизованной сессии и не передаётся в payload;
- приватные ключи и пароли серверу не передаются никогда;
- в БД сохраняются только старые/новые публичные ключи;
- первая серверная запись появляется сразу в состоянии `COPYING_CHAIN`; состояния `PREPARING` в БД нет;
- во время `KeyRotationStart` сервер захватывает тот же per-blockchain lock, что использует обычный `AddBlock`, и фиксирует непротиворечивый source tip.
## `KeyRotationStart`
Создаёт новую серверную сессию ротации.
### Request
```json
{
"op": "KeyRotationStart",
"requestId": "kr-1",
"payload": {
"newRootKey": "<Base58 или Base64 публичного ключа 32B>",
"newBlockchainKey": "<Base58 или Base64 публичного ключа 32B>",
"newClientKey": "<Base58 или Base64 публичного ключа 32B>",
"forkFromBlock": 120,
"forkFromHash": "<64 hex>",
"reasonCode": 1,
"comment": "Плановая смена пароля"
}
}
```
`reasonCode`:
1. обычная ротация;
2. возможная компрометация;
3. подтверждённая компрометация / rollback;
4. recovery.
Сервер самостоятельно берёт из текущего PDA:
- `oldRootKey`;
- `oldBlockchainKey`;
- `oldClientKey`;
- текущий `sourceBlockchainName`.
Также сервер самостоятельно фиксирует текущий tip цепочки. Клиент не может подменить эти значения в запросе.
`forkFromBlock/forkFromHash` обязаны указывать на реально существующий блок текущей активной цепочки.
Новые root/blockchain/client keys должны быть валидными 32-байтовыми публичными ключами, отличаться от соответствующих старых ключей и друг от друга.
После успеха создаётся `key_rotation_sessions` со статусом `COPYING_CHAIN`. `progressTotal` равен количеству будущих candidate-блоков: копия `0..forkFromBlock` плюс `TECH_FORK`.
### Success response
Ответ использует тот же payload состояния, что и `KeyRotationStatus`.
## `KeyRotationStatus`
Возвращает текущее состояние ротации авторизованного пользователя.
### Request
```json
{
"op": "KeyRotationStatus",
"requestId": "kr-status-1",
"payload": {}
}
```
Если активной ротации нет:
```json
{
"op": "KeyRotationStatus",
"requestId": "kr-status-1",
"status": 200,
"ok": true,
"payload": {
"rotationStatus": "NONE"
}
}
```
Если ротация есть, payload содержит:
- `rotationSessionId`;
- `rotationStatus`;
- `sourceBlockchainName` / `candidateBlockchainName`;
- старые и новые публичные root/blockchain/client keys;
- `forkFromBlock` / `forkFromHash`;
- `sourceTipBlock` / `sourceTipHash`;
- `reasonCode` / `comment`;
- `progressCurrent` / `progressTotal`;
- `pdaRotationSignature` (когда появится на последующем этапе);
- `walletMigrationStatus`;
- `messageMigrationStatus`;
- `lastError` / `retryCount`;
- `createdAtMs` / `updatedAtMs`.
Любая авторизованная сессия пользователя может читать этот статус. Состояние ротации принадлежит аккаунту, а не конкретному WebSocket-сеансу.
## `KeyRotationAddBlock`
Принимает один ANS-104 DataItem будущего fork на этапе `COPYING_CHAIN`. Candidate-блоки хранятся отдельно от обычной таблицы `blocks`, поэтому до переключения fork они **не создают лайки, ответы, каналы, связи и другие materialized effects**.
### Request
```json
{
"op": "KeyRotationAddBlock",
"requestId": "kr-block-1",
"payload": {
"blockNumber": 0,
"prevBlockHash": "",
"blockBytesB64": "<полный ANS-104 DataItem в Base64>"
}
}
```
Правила:
- операция доступна только при `rotationStatus=COPYING_CHAIN`;
- блоки принимаются строго последовательно: `0..forkFromBlock`, затем один `TECH_FORK` с номером `forkFromBlock+1`;
- каждый DataItem обязан быть подписан `newBlockchainKey`;
- для копируемых блоков Frame, ANS-104 tags, target и anchor должны полностью совпадать с исходным блоком; меняются только owner/signature/DataItem id;
- последний блок обязан быть `TECH_FORK`, а его parent key, fork point, старый tip, reason/comment и число отброшенных блоков должны совпадать с `KeyRotationStart`;
- повтор уже принятого идентичного блока безопасен и возвращает success; другой DataItem/hash на том же номере возвращает conflict;
- candidate-блоки попадают в отдельную очередь Arweave/Turbo publisher-а и имеют приоритет перед обычными блоками;
- `progressCurrent` увеличивается **только после фактической успешной публикации** DataItem в Arweave/Turbo. Поэтому `KeyRotationStatus` показывает реальный прогресс публикации, а не только приём сервером.
### Основные ошибки
- `KEY_ROTATION_NOT_COPYING` — ротация не находится в `COPYING_CHAIN`;
- `KEY_ROTATION_BLOCK_OUT_OF_ORDER` — пропущен предыдущий candidate-блок;
- `KEY_ROTATION_BLOCK_CONFLICT` — на этом номере уже сохранён другой candidate-блок;
- `KEY_ROTATION_BAD_SIGNATURE` — DataItem подписан не новым blockchain key;
- `KEY_ROTATION_FRAME_MISMATCH` — копируемый Frame отличается от исходной цепочки;
- `KEY_ROTATION_TAGS_MISMATCH` — изменены ANS-104 tags копируемого блока;
- `KEY_ROTATION_TECH_FORK_REQUIRED` — вместо финального `TECH_FORK` передан другой блок;
- ошибки `KEY_ROTATION_TECH_FORK_*` — поля `TECH_FORK` не соответствуют зафиксированной rotation session.
## Основные ошибки `KeyRotationStart`
- `AUTH_REQUIRED` — нет авторизованной сессии;
- `KEY_ROTATION_ALREADY_ACTIVE` — для login уже выполняется ротация;
- `KEY_ROTATION_BAD_FIELDS` — некорректные public keys/hash;
- `KEY_ROTATION_BAD_NEW_KEYS` — ключи не изменились либо новые ключи совпадают друг с другом;
- `KEY_ROTATION_BAD_REASON` — reason вне диапазона `1..4`;
- `KEY_ROTATION_COMMENT_TOO_LONG` — комментарий больше 1024 UTF-8 байт;
- `KEY_ROTATION_BAD_FORK_POINT` — неверная точка fork;
- `KEY_ROTATION_FORK_BLOCK_NOT_FOUND` — выбранный блок отсутствует;
- `KEY_ROTATION_FORK_HASH_MISMATCH` — переданный hash не совпадает с сервером;
- `KEY_ROTATION_SESSION_STALE` — авторизованная сессия содержит уже неактуальный blockchainName.
## `KeyRotationFinishChain`
Финализирует этап построения candidate-chain. Операция **не меняет PDA** и не переключает активную цепочку пользователя: она только доказывает, что будущий fork уже полностью сохранён и опубликован в Arweave/Turbo.
### Request
```json
{
"op": "KeyRotationFinishChain",
"requestId": "kr-finish-chain-1",
"payload": {}
}
```
Перед переходом в `CHAIN_READY` сервер повторно проверяет:
- количество candidate-блоков равно `progressTotal` (`0..forkFromBlock` + один `TECH_FORK`);
- каждый candidate-блок уже подтверждён Arweave/Turbo publisher-ом;
- номера идут строго `0,1,2,...` без дырок;
- `blockHash` и `DataItem id` совпадают с сохранёнными значениями;
- каждый DataItem подписан `newBlockchainKey`;
- `block 0` имеет нулевой `prevHash`, а каждый следующий блок ссылается на hash предыдущего Frame;
- копируемые блоки всё ещё байт-в-байт совпадают с выбранным префиксом исходной цепочки по Frame, tags, target и anchor;
- последний блок является корректным `TECH_FORK` и повторяет зафиксированные fork point, parent tip, reason/comment и число отброшенных блоков.
Только после успешной финальной проверки выполняется:
`COPYING_CHAIN -> CHAIN_READY`.
Повторный `KeyRotationFinishChain`, когда ротация уже находится в `CHAIN_READY`, идемпотентно возвращает success. Это позволяет безопасно вызывать операцию из нескольких сессий или повторить её после потери ответа.
### Основные ошибки
- `KEY_ROTATION_NOT_ACTIVE` — активная ротация отсутствует;
- `KEY_ROTATION_NOT_COPYING` — текущий этап уже не позволяет завершать candidate-chain;
- `KEY_ROTATION_CHAIN_INCOMPLETE` — сервер получил не все candidate-блоки;
- `KEY_ROTATION_CHAIN_NOT_PUBLISHED` — не все DataItem подтверждены Arweave/Turbo publisher-ом;
- `KEY_ROTATION_CANDIDATE_GAP` / `KEY_ROTATION_CHAIN_HASH_MISMATCH` — нарушена последовательность candidate-chain;
- `KEY_ROTATION_BAD_SIGNATURE` — сохранённый candidate не подтверждается новым blockchain key;
- `KEY_ROTATION_TECH_FORK_*` — финальный `TECH_FORK` больше не соответствует rotation session;
- `KEY_ROTATION_FINISH_CHAIN_RACE` — состояние ротации было одновременно изменено другой операцией.
## `KeyRotationRotatePda`
Фиксирует, что клиент уже локально подписал и отправил в Solana транзакцию полной ротации PDA.
Сервер **не получает приватные ключи и не подписывает транзакцию**. Клиент передаёт только публичную Solana transaction signature.
### Request
```json
{
"op": "KeyRotationRotatePda",
"requestId": "kr-rotate-pda-1",
"payload": {
"pdaRotationSignature": "<Base58 Solana transaction signature>"
}
}
```
Операция разрешена только после `CHAIN_READY`. Сервер атомарно сохраняет signature и переводит:
`CHAIN_READY -> ROTATING_PDA`.
После этого отмена ротации уже запрещена: отправленная Solana-транзакция может подтвердиться позже, даже если клиент потерял соединение.
Подтверждением успешной ротации служит **не сам факт наличия signature**, а фактический current PDA, увиденный Solana sync. Sync требует одновременного совпадения:
- `rootKey == newRootKey`;
- `blockchainKey == newBlockchainKey`;
- `clientKey == newClientKey`;
- `blockchainName == candidateBlockchainName`.
Только после этого сервер атомарно переводит:
`ROTATING_PDA -> PDA_ROTATED`.
Если Solana sync успел увидеть новое PDA раньше вызова `KeyRotationRotatePda`, он умеет подтвердить ожидаемые ключи прямо из `CHAIN_READY`. Поэтому потеря клиентского запроса после уже подтверждённой Solana-транзакции не оставляет ротацию зависшей. Если API-вызов всё же приходит, handler идемпотентно возвращает текущее подтверждённое состояние.
Повтор с той же signature идемпотентен. Другая signature для уже начатого `ROTATING_PDA` возвращает conflict.
### Основные ошибки
- `KEY_ROTATION_NOT_ACTIVE` — активной ротации нет;
- `KEY_ROTATION_NOT_CHAIN_READY` — candidate-chain ещё не подтверждена;
- `KEY_ROTATION_BAD_SOLANA_SIGNATURE` — signature отсутствует или не Base58;
- `KEY_ROTATION_PDA_SIGNATURE_CONFLICT` — для этой ротации уже сохранена другая signature;
- `KEY_ROTATION_ROTATE_PDA_FAILED` — внутренняя ошибка фиксации этапа.
## Автоматический rebuild после `PDA_ROTATED`
После подтверждения нового current PDA сервер сам выполняет:
`PDA_ROTATED -> REBUILDING_SERVER -> WALLET_MIGRATION`.
Во время rebuild:
- проверяется, что current PDA действительно указывает на `candidateBlockchainName/newBlockchainKey`;
- старая активная цепочка удаляется из рабочих PostgreSQL-таблиц;
- candidate-блоки повторно проходят обычный `AddBlock` validation/projection path;
- runtime-cache `to_bch_name` у логических ссылок `login + blockNumber + blockHash` перепривязывается к новому fork;
- входящие `likes_count/replies_count` пересчитываются;
- после `COMPLETE` временные строки candidate-chain удаляются из PostgreSQL.
Если rebuild прерывается, `REBUILDING_SERVER` остаётся активным, ошибка записывается в `lastError/retryCount`, а worker безопасно повторяет rebuild.
## `KeyRotationContinue`
Продолжает интерактивные post-PDA этапы. В текущей версии два будущих этапа являются честными заглушками:
- `WALLET_MIGRATION`: `walletMigrationStatus = NOT_IMPLEMENTED`, затем переход в `MESSAGE_MIGRATION`;
- `MESSAGE_MIGRATION`: `messageMigrationStatus = NOT_IMPLEMENTED`, затем `FINALIZING -> COMPLETE`.
Request:
```json
{
"op": "KeyRotationContinue",
"requestId": "kr-continue-1",
"payload": {}
}
```
`KeyRotationContinue` не переводит деньги и не перешифровывает DM. Это специально оставленные точки расширения для будущей реализации.
## `KeyRotationAbort`
Прерывает ротацию только пока Solana-ротация PDA ещё не могла быть отправлена:
- разрешено из `COPYING_CHAIN`;
- разрешено из `CHAIN_READY`;
- начиная с `ROTATING_PDA` отмена запрещена, процесс можно только довести вперёд.
Request:
```json
{
"op": "KeyRotationAbort",
"requestId": "kr-abort-1",
"payload": {}
}
```
При `ABORTED` временные candidate-строки удаляются из PostgreSQL. Уже опубликованные Arweave DataItem остаются неизменяемым сиротским историческим следом и не становятся активной цепочкой, поскольку PDA не переключён.
+46
View File
@@ -0,0 +1,46 @@
# GetMyBlockchain API
`GetMyBlockchain` — авторизованное постраничное чтение **только текущей активной версии собственного блокчейна**. API используется постоянным экраном «Мой блокчейн» и мастером смены ключей для выбора последнего доверенного блока.
Login и активный `blockchainName` сервер определяет из авторизованной сессии/current PDA; клиент не может запросить этим методом чужую цепочку.
## Request
```json
{
"op": "GetMyBlockchain",
"requestId": "my-bch-1",
"payload": {
"beforeBlock": 500,
"limit": 50,
"includeBlockBytes": false
}
}
```
Поля:
- `beforeBlock` — необязательный номер верхней границы страницы; если отсутствует, чтение начинается с current tip;
- `limit` — `1..100`, default `50`;
- `includeBlockBytes` — при `true` дополнительно вернуть полный ANS-104 DataItem в Base64.
## Response
Payload содержит:
- `login`;
- `blockchainName`;
- `tipBlockNumber` / `tipBlockHash`;
- `nextBeforeBlock` для следующей страницы;
- `blocks[]` в порядке от новых к старым.
Каждый элемент `blocks[]` содержит:
- `blockNumber`;
- `blockHash` / `prevBlockHash`;
- `timestampMs`;
- `msgType` / `msgSubType` / `msgVersion`;
- для target-блоков: `toLogin + toBlockNumber + toBlockHash`;
- `blockBytesB64`, только если запрошен `includeBlockBytes=true`.
После fork API показывает только новую активную ветку PostgreSQL. Исторические fork при необходимости восстанавливаются из Arweave/PDA history отдельным будущим viewer-механизмом.
@@ -15,7 +15,7 @@
## Быстрая карта типов ## Быстрая карта типов
- `type=0` — TECH: HEADER, CREATE_CHANNEL. - `type=0` — TECH: HEADER, CREATE_CHANNEL, FORK.
- `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 и обратные операции.
+37 -1
View File
@@ -12,7 +12,43 @@ TECH-тип покрывает системные записи цепочки.
- создание нового канала; - создание нового канала;
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`. - хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`.
3. `subType=2` — `TECH_FORK`
- первый новый блок после точной перепубликации выбранного префикса предыдущего fork новым blockchain key;
- связывает новую активную цепочку с предыдущей и фиксирует точку rollback/продолжения.
### `TECH_FORK` body (`version=1`)
Big-endian:
- `parentBlockchainKey[32]` — public key предыдущего fork;
- `forkPointBlockNumber[4]` — последний блок старой цепочки, сохранённый в новом fork;
- `forkPointBlockHash32[32]`;
- `forkPointTimestampMs[8]`;
- `parentTipBlockNumber[4]` — tip старой цепочки на момент начала ротации;
- `parentTipBlockHash32[32]`;
- `parentTipTimestampMs[8]`;
- `discardedBlocksCount[4]` — `parentTipBlockNumber - forkPointBlockNumber`;
- `reasonCode[1]`;
- `commentUtf8Length[2]`;
- `comment[N]` — произвольный комментарий пользователя, максимум 1024 UTF-8 байт.
`reasonCode`:
- `1` — `ROUTINE_ROTATION`: обычная смена пароля/ключей, компрометация не предполагается;
- `2` — `POSSIBLE_COMPROMISE`: возможная компрометация, неизвестные записи не подтверждены;
- `3` — `CONFIRMED_COMPROMISE_ROLLBACK`: обнаружены нежелательные/чужие записи и выполнен rollback;
- `4` — `RECOVERY`: восстановление доступа recovery-механизмом.
Правила:
- блоки `0..forkPointBlockNumber` в новом fork должны быть точными Frame-копиями выбранного префикса предыдущей цепочки;
- `TECH_FORK` идёт сразу после этого префикса и является первым действительно новым Frame нового fork;
- если история сохранена полностью, `forkPointBlockNumber == parentTipBlockNumber` и `discardedBlocksCount == 0`;
- если сохраняется только genesis, новый fork содержит прежний `block 0`, а `block 1` является `TECH_FORK`;
- новый blockchain key в body не дублируется: он определяется owner/signature нового ANS-104 DataItem.
## Назначение ## Назначение
- инициализация блокчейна; - инициализация блокчейна;
- управление набором каналов пользователя. - управление набором каналов пользователя;
- фиксация происхождения нового fork и причины ротации/rollback.
+16 -2
View File
@@ -14,7 +14,7 @@ TEXT-тип хранит сообщения, материалы и редакт
3. `subType=20` — `TEXT_REPLY` 3. `subType=20` — `TEXT_REPLY`
- ответ на сообщение; - ответ на сообщение;
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст. - target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
4. `subType=21` — `TEXT_EDIT_REPLY` 4. `subType=21` — `TEXT_EDIT_REPLY`
- редактирование ответа; - редактирование ответа;
@@ -23,7 +23,7 @@ TEXT-тип хранит сообщения, материалы и редакт
5. `subType=30` — `TEXT_RATING` 5. `subType=30` — `TEXT_RATING`
- target-based отзыв на конкретный блок; - target-based отзыв на конкретный блок;
- содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва; - содержит target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
- не является сообщением линии канала. - не является сообщением линии канала.
6. `subType=50` — `TEXT_REPOST` 6. `subType=50` — `TEXT_REPOST`
@@ -57,6 +57,20 @@ TEXT-тип хранит сообщения, материалы и редакт
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md). Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
## Общий target-формат TEXT
Для `TEXT_EDIT_POST`, `TEXT_REPLY`, `TEXT_EDIT_REPLY`, `TEXT_RATING` и `TEXT_REPOST` ссылка на цель хранится как:
```text
[1] toLoginLen (uint8)
[N] toLogin UTF-8
[4] toBlockGlobalNumber
[32] toBlockHash32
```
`blockchainName`/номер fork в подписываемые байты target не входит. Одинаковые `login + blockNumber + blockHash` считаются одной логической целью после перепубликации сохранённого префикса при fork.
## Правило для edit ## Правило для edit
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit. `EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
+16 -2
View File
@@ -4,11 +4,25 @@
1. `subType=1` — `REACTION_LIKE` 1. `subType=1` — `REACTION_LIKE`
- лайк на целевой блок; - лайк на целевой блок;
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`. - хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
2. `subType=2` — `REACTION_UNLIKE` 2. `subType=2` — `REACTION_UNLIKE`
- снятие лайка с целевого блока; - снятие лайка с целевого блока;
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`. - хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
## Назначение ## Назначение
- реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой). - реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой).
## Формат target
В подписанных байтах target больше не хранит имя fork/blockchain. Формат:
```text
[1] toLoginLen (uint8)
[N] toLogin UTF-8
[4] toBlockGlobalNumber
[32] toBlockHash32
```
Логическая идентичность цели: `toLogin + toBlockGlobalNumber + toBlockHash32`. Поэтому ссылка остаётся той же после fork, если номер и hash исходного блока сохранены.
+12 -1
View File
@@ -18,7 +18,18 @@ CONNECTION-тип описывает социальные связи и подп
## Общий формат payload ## Общий формат payload
- line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`) - line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`)
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) - target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`)
## Бинарный target
```text
[1] toLoginLen (uint8)
[N] toLogin UTF-8
[4] toBlockGlobalNumber
[32] toBlockHash32
```
Имя fork/blockchain в target не хранится.
## Правила target ## Правила target
+3 -3
View File
@@ -36,8 +36,8 @@
Все `STATUS_ACTION` используют один и тот же бинарный body-формат: Все `STATUS_ACTION` используют один и тот же бинарный body-формат:
```text ```text
[1] toBlockchainNameLen (uint8) [1] toLoginLen (uint8)
[N] toBlockchainName UTF-8 [N] toLogin UTF-8
[4] toBlockGlobalNumber [4] toBlockGlobalNumber
[32] toBlockHash32 [32] toBlockHash32
[2] textLenBytes (uint16) [2] textLenBytes (uint16)
@@ -46,7 +46,7 @@
Где: Где:
- `toBlockchainName` — блокчейн, в котором находится целевой материал; - `toLogin` — login владельца целевого блока; номер fork в target не хранится;
- `toBlockGlobalNumber` — номер целевого блока; - `toBlockGlobalNumber` — номер целевого блока;
- `toBlockHash32` — хэш целевого блока; - `toBlockHash32` — хэш целевого блока;
- `text` — опциональное пояснение пользователя к статусу. - `text` — опциональное пояснение пользователя к статусу.
+7
View File
@@ -1,3 +1,10 @@
## 2026-09-27 — TECH_FORK v1
- Добавлен `type=0 / subType=2 / version=1` (`TECH_FORK`).
- `TECH_FORK` фиксирует parent blockchain key, точку сохранённой истории, старый tip, число отброшенных блоков, reason code и пользовательский комментарий.
- Новый blockchain key не дублируется в body: он определяется подписью нового ANS-104 DataItem.
- Зафиксированы четыре причины: обычная ротация, возможная компрометация, подтверждённая компрометация с rollback и recovery.
# История изменений документации блокчейна # История изменений документации блокчейна
## 2026-09-23 — Turbo transport для individual ANS-104 DataItems ## 2026-09-23 — Turbo transport для individual ANS-104 DataItems
+19 -19
View File
@@ -2,10 +2,10 @@
> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.** > **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.**
> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся > Этот файл описывает, как из пароля получается секрет и как из секрета выводятся
> все ключи (root, blockchain, device/Solana, homeserver) — формулами, байт-в-байт. > все ключи (root, blockchain, client, homeserver) — формулами, байт-в-байт.
> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула > Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула
> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь > ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь
> dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**. > blockchain key ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
> Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура). > Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура).
> Здесь — только механика. Документ намеренно краткий. > Здесь — только механика. Документ намеренно краткий.
@@ -55,29 +55,29 @@ seed(32) = SHA-256(material)
| Ключ | Суффикс | Назначение (кратко) | | Ключ | Суффикс | Назначение (кратко) |
|------|---------|---------------------| |------|---------|---------------------|
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). | | root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). | | blockchain | `blockchain.key` | Подписывает пользовательские блоки/ANS-104 DataItem и является текущим Solana-wallet/fee payer. |
| device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. | | client | `client.key` | Общий клиентский ключ для DM/устройств и derivation отдельного Arweave SAWD-кошелька; не является текущим Solana-wallet. |
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. | | homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
Полные роли каждого ключа — в `docs/Keys/README.md`. Полные роли каждого ключа — в `docs/Keys/README.md`.
--- ---
## 3. Solana-ключ ## 3. Solana-кошелёк и authority
Отдельного «солана-ключа» нет. На Solana работают два ключа: Отдельного «solana.key» нет. В актуальной модели Solana-wallet пользователя — **активный `blockchain.key`**:
- **`client.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(clientPub)`. - адрес кошелька = `base58(activeBlockchainPub)`;
Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`. - им оплачивается регистрация (`create_user_pda`) и обычные `update_user_pda`;
Пополнять SOL нужно именно на этот адрес. - обычное обновление PDA авторизуется активным blockchain key;
- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer. - новый fork подписывает новое PDA новым blockchain key, а старый активный blockchain key разрешает обычный переход;
- полная смена пароля/ключей — особый recovery-переход: старый `root.key` дополнительно разрешает одно новое unsigned PDA state, а **новый blockchain key** подписывает тот же hash и становится новым wallet/authority.
Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1 `root.key` не является fee payer. Он используется как холодное дополнительное разрешение только для полной ротации root + client + blockchain fork.
(«create/update оплачиваются с `client_key`», «root_key — не fee payer»).
Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью `client.key` больше не используется как Solana-wallet/fee payer. Он остаётся клиентским криптографическим ключом (DM/устройства) и входом в отдельный протокол derivation Arweave-кошелька.
(`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый
кошелёк и плательщик комиссий**. Полное описание ролей — `docs/Keys/README.md`. При fork Solana-адрес меняется вместе с blockchain key. Перенос SOL со старого blockchain-wallet на новый является отдельным необязательным этапом ротации; в текущей первой реализации этот этап оставлен явной заглушкой.
--- ---
@@ -117,9 +117,9 @@ homeserver.key:home-b -> ключ B
- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160). - `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160).
### Solana-ключ / адрес кошелька (UI) ### Solana-ключ / адрес кошелька (UI)
- `shine-UI/js/pages/registration-payment-view.js` — `deriveUserWalletAddress`: адрес = `base58(clientPub)` (~113). - `shine-UI/js/pages/registration-payment-view.js` — адрес пополнения = `base58(blockchainPub)`.
- `shine-UI/js/pages/topup-view.js` — `clientWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.clientPair`. - `shine-UI/js/pages/topup-view.js` — тот же адрес из `preGeneratedKeyBundle.blockchainPair`.
Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `client.key`, мимо `masterSecret`) удалён. - `shine-UI/js/services/solana-wallet-service.js` — текущий пользовательский Solana-wallet загружается из сохранённого `blockchainKey`.
### Деривация ключей (прошивка ESP32) ### Деривация ключей (прошивка ESP32)
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino` - `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
@@ -147,7 +147,7 @@ homeserver.key:home-b -> ключ B
1. Этот документ — источник истины по деривации секрета и ключей. 1. Этот документ — источник истины по деривации секрета и ключей.
2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа, 2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа,
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь dev-ключ ↔ Solana-адрес — разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь blockchain key ↔ Solana-адрес —
**обязательно** отражать здесь в том же изменении. **обязательно** отражать здесь в том же изменении.
3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма. 3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма.
4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости. 4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости.
+11 -14
View File
@@ -8,9 +8,9 @@
В SHiNE у пользователя есть несколько уровней ключей: В SHiNE у пользователя есть несколько уровней ключей:
- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`). - `root key` - холодный recovery-ключ: при полной ротации дополнительно разрешает замену root + client + blockchain fork. Это не кошелёк.
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя. - `blockchain key` - ключ записи в персональный SHiNE-блокчейн и текущий Solana-wallet/fee payer пользователя.
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей. - `client key` - общий ключ пользовательских устройств для повседневной работы, звонков и DM; Solana-wallet им больше не является.
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере. - `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы. Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
@@ -21,16 +21,13 @@
Назначение: Назначение:
- регистрация пользователя в Solana; - холодное recovery-разрешение для полной смены ключей;
- создание и обновление пользовательской PDA-записи; - подтверждение атомарной ротации `root + client + новый blockchain fork`;
- вызов критически важных Solana-функций; - восстановительные сценарии повышенного уровня доверия.
- изменение главных настроек пользователя;
- управление остальными ключами;
- подтверждение операций, которые должны иметь максимальный уровень доверия.
`root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя. Обычные PDA-update **не требуют root key**: их выполняет активный blockchain key. При полной ротации старый root подписывает тот же hash нового unsigned PDA state, который подписывает новый blockchain key. Так root разрешает переход, не становясь повседневным ключом.
Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `client key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `docs/Keys/DERIVATION.md`, §3. Важно не путать recovery-authority и кошелёк: `root key` не является fee payer. Текущий Solana-wallet/fee payer — активный `blockchain key`. Подробнее — `docs/Keys/DERIVATION.md`, §3.
## `blockchain key` ## `blockchain key`
@@ -40,7 +37,8 @@
- подпись записей в персональном блокчейне пользователя; - подпись записей в персональном блокчейне пользователя;
- подтверждение действий, которые должны попасть в SHiNE-блокчейн; - подтверждение действий, которые должны попасть в SHiNE-блокчейн;
- разделение полномочий между главным Solana-ключом и ключом ежедневной записи. - обычные обновления пользовательской PDA;
- текущий Solana-wallet/fee payer (`base58(active blockchain public key)`).
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи: У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
@@ -59,7 +57,6 @@
- повседневные входящие и исходящие личные сообщения; - повседневные входящие и исходящие личные сообщения;
- звонки и связанные с ними сообщения; - звонки и связанные с ними сообщения;
- self-messages, то есть внутренние сообщения пользователя самому себе; - self-messages, то есть внутренние сообщения пользователя самому себе;
- мелкие Solana-расходы на текущие операции;
- derivation Arweave-кошелька; - derivation Arweave-кошелька;
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу. - оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
@@ -158,7 +155,7 @@ Self-message - это сообщение пользователя самому
## Связанные документы ## Связанные документы
- `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код). - `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`blockchain.key`/`client.key`/`homeserver.key:<имя>`, Solana-wallet, ссылки на код).
- `docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений. - `docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений.
- `docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений. - `docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений.
- `docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна. - `docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
@@ -584,3 +584,28 @@ SYNC_POLL_INTERVAL_SECONDS=300
- server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес. - server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес.
Create/update транзакции больше не реконструируются байт-в-байт из instruction args. После обнаружения изменения sync-модуль перечитывает фактическую текущую PDA через Solana RPC и декодирует её. Это исключает дублирование on-chain сериализации. `close_legacy_pda` не создаёт новое состояние PDA и для runtime-sync не является пользовательским update. Create/update транзакции больше не реконструируются байт-в-байт из instruction args. После обнаружения изменения sync-модуль перечитывает фактическую текущую PDA через Solana RPC и декодирует её. Это исключает дублирование on-chain сериализации. `close_legacy_pda` не создаёт новое состояние PDA и для runtime-sync не является пользовательским update.
---
## Server-local key rotation state (PostgreSQL v26)
Начиная с migration v26 сервер хранит локальное состояние длительной смены ключей отдельно от данных Solana PDA.
В `solana_user_pda_current` добавлены локальные поля:
- `rotation_status` — быстрый текущий статус (`NONE` в обычном режиме);
- `rotation_session_id` — ссылка на текущую запись `key_rotation_sessions`.
Эти поля **не являются частью PDA**, не приходят из Solana и не должны перезаписываться обычным Solana sync upsert-ом.
Подробный прогресс хранится в `key_rotation_sessions`: только публичные old/new root/blockchain/client keys, выбранная точка fork, старый tip, reason/comment, прогресс, ошибка/retry и статусы необязательных wallet/DM этапов. Пароли и приватные ключи в PostgreSQL не сохраняются.
Первая серверная запись создаётся сразу в `COPYING_CHAIN`; состояния `PREPARING` в БД нет. Завершённые `COMPLETE`/`ABORTED` sessions остаются как журнал, а `solana_user_pda_current.rotation_status` возвращается в `NONE`.
### Candidate blocks ротации (PostgreSQL v27)
Начиная с migration v27 будущая ветка во время `COPYING_CHAIN` хранится в отдельной таблице `key_rotation_candidate_blocks`. Она не является частью текущего materialized blockchain state и не должна попадать в обычную `blocks` до финального переключения fork.
Для каждого candidate DataItem сохраняются rotation session, login, candidate blockchain name, block number/hash, полный ANS-104 DataItem, DataItem id и статус публикации. Уникальность `(rotation_session_id, block_number)` запрещает две разные версии одного candidate-блока.
Arweave/Turbo publisher обрабатывает candidate-блоки приоритетно. `key_rotation_sessions.progress_current` отражает число DataItem, уже реально опубликованных publisher-ом, а не число принятых API-сервером.
@@ -16,7 +16,7 @@
Основные данные: Основные данные:
- `RootKeyBlock` — cold recovery authority; - `RootKeyBlock` — cold recovery authority;
- `ClientKeyBlock` — клиентский/кошелёчный ключ; - `ClientKeyBlock` — клиентский ключ для DM/устройств; Solana-wallet/fee payer — активный blockchain key;
- `BlockchainRegistryBlock` — append-only список fork: `blockchain_key[32] + created_at_ms:u64 + paid_limit_bytes:u32`; последний fork активен; - `BlockchainRegistryBlock` — append-only список fork: `blockchain_key[32] + created_at_ms:u64 + paid_limit_bytes:u32`; последний fork активен;
- необязательный `ServerProfileBlock` — в 1.2 ровно один адрес сервера; - необязательный `ServerProfileBlock` — в 1.2 ровно один адрес сервера;
- необязательный `AccessServersBlock` — в 1.2 максимум один access server. - необязательный `AccessServersBlock` — в 1.2 максимум один access server.
-65
View File
@@ -1,65 +0,0 @@
# UI v3–v4: что нужно от сервера и что знать разработчику
Макет V3/V4 перенесён в настоящий код (v3) и доведён по экранам (v4): палитра «Сияние», лента канала, обсуждение,
личный чат, вход и регистрация, уведомления, настройки, просмотр фото. Всё сделано на текущем API без его изменений.
Там, где интерфейсу не хватает сервера, UI к этому готов: нет данных – элемент просто не появляется.
## Нужно от сервера
1. **Кто недавно ответил** – миниатюры рядом со счётчиком обсуждения в ленте канала.
Поле `recentRepliers: string[]` (до двух логинов, свежие первыми) у сообщений в `GetChannelMessages`
и `GetMessageThread`. UI: `channel-view.js`, `mapApiMessageToPost` → `recentRepliers`. Нет поля – нет миниатюр.
2. **«Не уведомлять об ответах» в обсуждении.** Пункт меню обсуждения сейчас запоминает выбор только на устройстве
(`localStorage`, ключ `shine-ui-thread-muted-v1`) и на уведомления не влияет. Нужна настройка пользователя
«без уведомлений об ответах в этом обсуждении» (по ссылке на исходную запись), учитываемая при создании
уведомлений `reply`. Удобно через существующий `UpsertUserSetting`.
3. **Ссылка на комментарий.** «Поделиться комментарием» даёт ссылку на обсуждение с якорем `#comment-<blockNumber>`.
Роутер якорь игнорирует. Нужно: при открытии обсуждения с таким якорем прокрутить к комментарию и подсветить
(в UI для этого есть `jumpTo` в `channel-thread-view.js`).
4. **Уведомления об ответах – на что ответили** (по желанию). В уведомлении есть только текст ответа (`sourceText`).
Чтобы показать «в ответ на: …», нужен короткий текст исходного сообщения (`targetText`) в `GetNotifications`.
5. **Стоимость публикации** (экран «Публикация · подтверждение» макета). Шага подтверждения в приложении нет –
запись уходит сразу. Если публикация станет платной или будет ждать Arweave, нужна цена до отправки.
## Нужно от разработчика
6. **Удалить `pages/key-storage-view.js` и маршрут `/key-storage`.** Экран никуда не подключён: в поля вписаны
описания ключей, «OK» вызывает `authorizeSession()` без данных. Настоящий выбор ключей – `registration-keys-view.js`
(переделан в v4). Вместе с ним можно убрать `.key-storage-option*` / `.key-storage-note*` в `developer-tools.css`.
## Что важно знать об изменениях v4
- **Второстепенные кнопки** – одна роль во всём приложении, включая экраны устройств и серверов: прозрачная, рамка, цвет `--action`.
- **Полоса прокрутки скрыта по умолчанию** (`scrollbar: 'hidden'` в режимах оболочки `app.js`) – как было у каналов,
ленты и обсуждения. Прокрутка работает, контент не прыгает при появлении полосы.
- **Один зазор под шапкой:** `--page-top-gap` в `main.css` (12 px). Его берут `app-shell.css`, каналы и экраны входа –
свои отступы сверху экранам не нужны.
- **Шапка корневых разделов** (Чаты, Каналы, Связи, Уведомления, Профиль) – заголовок по центру, 24 px (`.topbar--root`).
- **Переключатели `.tabs`** – высотой с поле ввода (44 px, кнопки 34 px); `.tabs--dense` – для 4–5 коротких пунктов
(фильтр чатов вместо чипов).
- **Новые общие компоненты:** `components/media-viewer.js` + `styles/components/media-viewer.css` (фото/видео на весь экран,
«1/3» и «Скачать» под фото; заменил `openAttachmentViewer` и кнопку скачивания на фото), `components/shine-wordmark.js`
(надпись «СИЯНИЕ» в контурах, шрифт Tenor Sans, OFL), переключатель `.switch` (перенесён из `settings.css` в `primitives.css`).
- **Иконки** – только из `ui-icon.js` (добавлены `eye`, `eyeOff`; `iconHtml(name, filled, className)`). Текстовые символы
вместо иконок («↻», «+», «✕») заменены.
- **«Дополнительные настройки»** теперь открываются пятью быстрыми нажатиями на заголовок «Настройки»
(логотип над настройками убран).
- **Стартовый экран:** знак по центру, кнопки внизу; сияние мандалы – два вращающихся слоя в `preauth.css`
(`.auth-logo-wrap::before/::after`), фаза случайная; при `prefers-reduced-motion` неподвижно.
- **Тексты:** в строках интерфейса только среднее тире с пробелами «–» (длинное «—» заменено везде, кроме комментариев).
- Уведомления – список как в чатах; «Настройки входа» – одна карточка серверов со статусом-пилюлей, проверка при открытии;
вход с другого устройства – код, таймер, три шага.
## Найденные ошибки (не наши)
7. `styles/components/avatar.css`: `url("../assets/glass_overlay_faithful.png")` ведёт в `styles/assets/…` – файла там нет,
стеклянный ободок аватара не загружается. Путь должен быть `../../assets/…`.
8. Обработчик ошибок в `index.html` показывал красную плашку «BOOT ERROR ResizeObserver loop…» – это предупреждение
браузера, не ошибка. В v3 отсеивается.
9. Пункты 1–2 из `ISSUES-for-dev.md` (подписки у логинов с заглавными, превью «Сообщение недоступно») по-прежнему актуальны.
10. Вход по паролю на `http://` по адресу в локальной сети не работает («Криптография браузера недоступна»): WebCrypto есть
только на https и localhost. Это ограничение браузера – для проверки с телефона нужен https.
@@ -90,14 +90,14 @@
## Кто оплачивает create/update user_pda ## Кто оплачивает create/update user_pda
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`. - И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с активного `blockchainKey`.
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя или сервера. - В UI это означает, что Solana fee payer всегда берётся из текущего активного `blockchain key` пользователя или сервера.
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию. - `rootKey` не оплачивает транзакцию; он дополнительно авторизует только полную ротацию ключей.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`. - Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес текущего `blockchainKey`.
## Важно ## Важно
- `init_users_economy_config` выполняется один раз на программу. Если PDA уже создан, повторный вызов вернёт ошибку `already initialized`. - `init_users_economy_config` выполняется один раз на программу. Если PDA уже создан, повторный вызов вернёт ошибку `already initialized`.
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: транзакцию оплачивает `clientKey`, а содержимое записи подписывает `rootKey`. - Серверные приватные ключи для Solana не используются как отдельный backend-wallet: транзакцию оплачивает `blockchainKey`. Обычное состояние PDA подписывает blockchain key; root дополнительно участвует только в полной ротации ключей.
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина. - `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
- При новом devnet deploy планируется использовать те же program keypair, чтобы `program id` на devnet совпадали с mainnet. - При новом devnet deploy планируется использовать те же program keypair, чтобы `program id` на devnet совпадали с mainnet.
+14 -16
View File
@@ -6,7 +6,7 @@
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual"
/> />
<meta name="theme-color" content="#121419" /> <meta name="theme-color" content="#12141f" />
<base href="/" /> <base href="/" />
<link rel="manifest" href="./manifest.webmanifest" /> <link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" /> <link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
@@ -45,7 +45,7 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
<script> <script>
(function attachStylesWithBuildHash() { (function attachStylesWithBuildHash() {
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev'); const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/chips.css', './styles/components/nav-list.css', './styles/components/palette-editor.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/compose.css', './styles/components/attachments.css', './styles/components/media-viewer.css', './styles/components/channel-editor.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/profile-page.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css']; const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/chips.css', './styles/components/nav-list.css', './styles/components/palette-editor.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/channel-editor.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/profile-page.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
cssFiles.forEach((file) => { cssFiles.forEach((file) => {
const link = document.createElement('link'); const link = document.createElement('link');
link.rel = 'stylesheet'; link.rel = 'stylesheet';
@@ -208,19 +208,19 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
const formatReportText = (report) => { const formatReportText = (report) => {
const lines = [ const lines = [
`Ошибка: ${report.title || report.kind || 'unknown'}`, `Ошибка: ${report.title || report.kind || 'unknown'}`,
`Описание: ${report.message || '–'}`, `Описание: ${report.message || '—'}`,
`Окно: ${report.pageTitle || '–'}`, `Окно: ${report.pageTitle || '—'}`,
`Маршрут: ${report.route || '–'}`, `Маршрут: ${report.route || '—'}`,
`URL: ${report.href || '–'}`, `URL: ${report.href || '—'}`,
`Видимость: ${report.pageVisibility || '–'}`, `Видимость: ${report.pageVisibility || '—'}`,
`Время: ${new Date(Number(report.clientTs || Date.now())).toISOString()}`, `Время: ${new Date(Number(report.clientTs || Date.now())).toISOString()}`,
`UA: ${report.userAgent || '–'}`, `UA: ${report.userAgent || '—'}`,
`Экран: ${report.viewport ? `${report.viewport.width}x${report.viewport.height} @${report.viewport.dpr || 1}x` : '–'}`, `Экран: ${report.viewport ? `${report.viewport.width}x${report.viewport.height} @${report.viewport.dpr || 1}x` : '—'}`,
`Монитор: ${report.screenInfo ? `${report.screenInfo.width}x${report.screenInfo.height}` : '–'}`, `Монитор: ${report.screenInfo ? `${report.screenInfo.width}x${report.screenInfo.height}` : '—'}`,
`Источник: ${report.sourceUrl || '–'}`, `Источник: ${report.sourceUrl || '—'}`,
`Строка: ${Number.isFinite(report.lineNumber) ? report.lineNumber : '–'}`, `Строка: ${Number.isFinite(report.lineNumber) ? report.lineNumber : '—'}`,
`Колонка: ${Number.isFinite(report.columnNumber) ? report.columnNumber : '–'}`, `Колонка: ${Number.isFinite(report.columnNumber) ? report.columnNumber : '—'}`,
`Тип причины: ${report.reasonType || '–'}`, `Тип причины: ${report.reasonType || '—'}`,
]; ];
if (report.stack) { if (report.stack) {
lines.push('Stack:', report.stack); lines.push('Stack:', report.stack);
@@ -418,8 +418,6 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
} catch {} } catch {}
}; };
window.addEventListener('error', (e) => { window.addEventListener('error', (e) => {
// Предупреждение браузера о переносе замеров ResizeObserver на следующий кадр – не ошибка
if (/^ResizeObserver loop/.test(String(e?.message || ''))) return;
show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`, { show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`, {
kind: 'window_error', kind: 'window_error',
message: e?.message || '', message: e?.message || '',
+24 -3
View File
@@ -16,6 +16,7 @@ import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'
import { initPwaPush } from './services/pwa-push-service.js'; import { initPwaPush } from './services/pwa-push-service.js';
import { initCallUiOverlay } from './services/call-ui-service.js'; import { initCallUiOverlay } from './services/call-ui-service.js';
import { showToast } from './services/channels-ux.js'; import { showToast } from './services/channels-ux.js';
import { KeyRotationClient } from './services/key-rotation-service.js';
import { import {
handleCallPushAction, handleCallPushAction,
handleIncomingCallInvite, handleIncomingCallInvite,
@@ -73,6 +74,8 @@ import * as profileEditView from './pages/profile-edit-view.js';
import * as profilesView from './pages/profiles-view.js'; import * as profilesView from './pages/profiles-view.js';
import * as walletView from './pages/wallet-view.js?v=202609260900'; import * as walletView from './pages/wallet-view.js?v=202609260900';
import * as settingsView from './pages/settings-view.js'; import * as settingsView from './pages/settings-view.js';
import * as myBlockchainView from './pages/my-blockchain-view.js';
import * as keyRotationView from './pages/key-rotation-view.js';
import * as accessServersView from './pages/access-servers-view.js'; import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js';
import * as advancedSettingsView from './pages/advanced-settings-view.js'; import * as advancedSettingsView from './pages/advanced-settings-view.js';
@@ -142,6 +145,8 @@ const routes = {
'profiles-view': profilesView, 'profiles-view': profilesView,
'wallet-view': walletView, 'wallet-view': walletView,
'settings-view': settingsView, 'settings-view': settingsView,
'my-blockchain-view': myBlockchainView,
'key-rotation-view': keyRotationView,
'access-servers-view': accessServersView, 'access-servers-view': accessServersView,
'developer-settings-view': developerSettingsView, 'developer-settings-view': developerSettingsView,
'advanced-settings-view': advancedSettingsView, 'advanced-settings-view': advancedSettingsView,
@@ -326,7 +331,7 @@ function syncViewportMetrics() {
0, 0,
stableViewportHeightPx - Math.min(currentLayoutHeightPx, visualBottomPx), stableViewportHeightPx - Math.min(currentLayoutHeightPx, visualBottomPx),
); );
// Address bar Android обычно даёт небольшую дельту; клавиатура – заметно больше. // Address bar Android обычно даёт небольшую дельту; клавиатура — заметно больше.
const keyboardOffsetPx = textEntryFocused && rawKeyboardOffsetPx >= 100 const keyboardOffsetPx = textEntryFocused && rawKeyboardOffsetPx >= 100
? rawKeyboardOffsetPx ? rawKeyboardOffsetPx
: 0; : 0;
@@ -433,7 +438,7 @@ const DEFAULT_SHELL_MODE = Object.freeze({
contentUnderTopbar: true, contentUnderTopbar: true,
contentUnderBottom: false, contentUnderBottom: false,
scrollContainer: 'screen', scrollContainer: 'screen',
scrollbar: 'hidden', // как в мобильных приложениях: прокрутка есть, полосы нет и контент не прыгает scrollbar: 'auto',
}); });
function normalizeShellMode(mode = {}, showAppChrome = true) { function normalizeShellMode(mode = {}, showAppChrome = true) {
@@ -1273,7 +1278,7 @@ window.addEventListener('error', (event) => {
}, },
}); });
// Кросс-ориджин ошибки приходят как «Script error.» без файла/строки/стека – // Кросс-ориджин ошибки приходят как «Script error.» без файла/строки/стека —
// алертом такие не показываем (в лог и captureClientError выше уже записаны). // алертом такие не показываем (в лог и captureClientError выше уже записаны).
const isOpaqueCrossOriginError = String(event.message || '') === 'Script error.' const isOpaqueCrossOriginError = String(event.message || '') === 'Script error.'
&& !event.filename && !(event.error?.stack); && !event.filename && !(event.error?.stack);
@@ -1376,6 +1381,21 @@ function attachPageScrollToBottom(pageId, screen) {
}); });
} }
async function redirectToActiveKeyRotationIfNeeded() {
if (!state.session.isAuthorized || state.session.isLocalDemo) return false;
try {
const rotation = await new KeyRotationClient(authService).status();
const status = String(rotation?.rotationStatus || 'NONE');
if (status !== 'NONE' && getRoute().pageId !== 'key-rotation-view') {
navigate('key-rotation-view');
return true;
}
} catch (error) {
console.warn('[key-rotation] status check failed', error);
}
return false;
}
function renderApp() { function renderApp() {
syncTrackedRouteHistory(window.location.pathname || '/'); syncTrackedRouteHistory(window.location.pathname || '/');
const route = getRoute(); const route = getRoute();
@@ -1566,6 +1586,7 @@ async function init() {
setSessionAuthorizedHandler(() => { setSessionAuthorizedHandler(() => {
void ensureSessionRuntimeStarted(); void ensureSessionRuntimeStarted();
void processPendingCallPushActionIfPossible(); void processPendingCallPushActionIfPossible();
void redirectToActiveKeyRotationIfNeeded();
}); });
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
@@ -9,7 +9,6 @@ import {
import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js'; import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js';
import { escapeHtml, formatBytes, getAttachmentKind, normalizeAttachment } from '../services/attachment-format.js'; import { escapeHtml, formatBytes, getAttachmentKind, normalizeAttachment } from '../services/attachment-format.js';
import { getStoredSolanaWalletChoices } from '../services/solana-wallet-service.js'; import { getStoredSolanaWalletChoices } from '../services/solana-wallet-service.js';
import { iconHtml } from './ui-icon.js';
import { import {
estimateTurboUploadPrice, estimateTurboUploadPrice,
formatTurboCredits, formatTurboCredits,
@@ -126,7 +125,7 @@ export function markArweaveAttachmentPlaced(login, attachment) {
export function formatArweaveHistoryTime(ms) { export function formatArweaveHistoryTime(ms) {
const value = Number(ms || 0); const value = Number(ms || 0);
if (!Number.isFinite(value) || value <= 0) return '–'; if (!Number.isFinite(value) || value <= 0) return '—';
const diffMs = Date.now() - value; const diffMs = Date.now() - value;
if (diffMs >= 0 && diffMs < RECENT_UPLOAD_MS) { if (diffMs >= 0 && diffMs < RECENT_UPLOAD_MS) {
const minutes = Math.max(1, Math.floor(diffMs / 60000)); const minutes = Math.max(1, Math.floor(diffMs / 60000));
@@ -137,7 +136,7 @@ export function formatArweaveHistoryTime(ms) {
export function formatArweaveHistoryDateTime(ms) { export function formatArweaveHistoryDateTime(ms) {
const value = Number(ms || 0); const value = Number(ms || 0);
if (!Number.isFinite(value) || value <= 0) return '–'; if (!Number.isFinite(value) || value <= 0) return '—';
return new Date(value).toLocaleString('ru-RU'); return new Date(value).toLocaleString('ru-RU');
} }
@@ -291,7 +290,7 @@ export function openArweaveAttachmentManager({
mode = 'attachment', mode = 'attachment',
historyPurpose = '', historyPurpose = '',
uploadTransport = 'turbo', uploadTransport = 'turbo',
turboKeySource = 'client', turboKeySource = 'blockchain',
dialogTitle = '', dialogTitle = '',
uploadButtonLabel = '', uploadButtonLabel = '',
initialFile = null, initialFile = null,
@@ -407,20 +406,9 @@ export function openArweaveAttachmentManager({
turboKeyChoices = await getStoredSolanaWalletChoices({ turboKeyChoices = await getStoredSolanaWalletChoices({
login: cleanLogin, login: cleanLogin,
storagePwd: cleanStoragePwd, storagePwd: cleanStoragePwd,
includeRoot: true,
});
turboKeyChoices.sort((a, b) => {
if (a?.keySource === b?.keySource) return 0;
if (a?.keySource === 'client') return -1;
if (b?.keySource === 'client') return 1;
return 0;
}); });
if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) { if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) {
selectedTurboKeySource = String( selectedTurboKeySource = String(turboKeyChoices[0]?.keySource || 'blockchain');
turboKeyChoices.find((item) => item.keySource === 'client')?.keySource
|| turboKeyChoices[0]?.keySource
|| 'client'
);
} }
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource); writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
} catch (error) { } catch (error) {
@@ -431,10 +419,10 @@ export function openArweaveAttachmentManager({
const renderTurboMeta = (mainMetaEl, previewMetaEl) => { const renderTurboMeta = (mainMetaEl, previewMetaEl) => {
if (!mainMetaEl || !previewMetaEl) return; if (!mainMetaEl || !previewMetaEl) return;
const turboAddress = String(balanceInfo?.address || priceInfo?.address || '').trim(); const turboAddress = String(balanceInfo?.address || priceInfo?.address || '').trim();
const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '–'; const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '—';
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '–'; const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—';
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '–'; const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—';
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '–'; const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—';
const isFreeSize = selectedFile && combinedUploadSize() <= getFreeTurboUploadBytesLimit(); const isFreeSize = selectedFile && combinedUploadSize() <= getFreeTurboUploadBytesLimit();
const summary = selectedFile const summary = selectedFile
? `Размер ${escapeHtml(fileSize)} · ${isFreeSize ? `Размер ${escapeHtml(fileSize)} · ${isFreeSize
@@ -445,10 +433,10 @@ export function openArweaveAttachmentManager({
${summary ? `<div class="ar-attachment-summary">${summary}</div>` : ''} ${summary ? `<div class="ar-attachment-summary">${summary}</div>` : ''}
<details class="ar-attachment-details"> <details class="ar-attachment-details">
<summary>Подробнее</summary> <summary>Подробнее</summary>
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '–'))}</div> <div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
<div>Баланс Turbo: ${escapeHtml(turboCredits)}</div> <div>Баланс Turbo: ${escapeHtml(turboCredits)}</div>
<div>SOL на этом ключе: ${solBalance}</div> <div>SOL на этом ключе: ${solBalance}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '–')}</div> <div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
<div>Цена Turbo: ${escapeHtml(uploadPrice)}</div> <div>Цена Turbo: ${escapeHtml(uploadPrice)}</div>
<div>Бесплатно до ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))}</div> <div>Бесплатно до ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))}</div>
</details> </details>
@@ -459,8 +447,8 @@ export function openArweaveAttachmentManager({
<div><strong>Файл превью</strong></div> <div><strong>Файл превью</strong></div>
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div> <div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div> <div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '–')}</div> <div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '–')}</div> <div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '—')}</div>
`; `;
} }
}; };
@@ -521,7 +509,7 @@ export function openArweaveAttachmentManager({
mainMetaEl.innerHTML = ` mainMetaEl.innerHTML = `
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile?.name || 'file')}</div>`} ${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile?.name || 'file')}</div>`}
<div>Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}</div> <div>Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '–')}</div> <div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
<div>Цена: ${escapeHtml(Number(priceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div> <div>Цена: ${escapeHtml(Number(priceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
<div>Баланс кошелька: ${escapeHtml(Number(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div> <div>Баланс кошелька: ${escapeHtml(Number(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
`; `;
@@ -531,8 +519,8 @@ export function openArweaveAttachmentManager({
<div><strong>Файл превью</strong></div> <div><strong>Файл превью</strong></div>
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div> <div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div> <div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '–')}</div> <div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '–')}</div> <div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '—')}</div>
`; `;
} }
}; };
@@ -625,7 +613,7 @@ export function openArweaveAttachmentManager({
const showUpload = async () => { const showUpload = async () => {
const turboMode = isTurboUpload(); const turboMode = isTurboUpload();
const titleText = effectiveDialogTitle const titleText = effectiveDialogTitle
|| (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')); || (turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')));
const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить'); const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить');
const canShowHistory = allowHistorySelection; const canShowHistory = allowHistorySelection;
const canShowExisting = allowExistingTxInput; const canShowExisting = allowExistingTxInput;
@@ -634,11 +622,11 @@ export function openArweaveAttachmentManager({
<div class="modal-card stack ar-attachment-manager-card"> <div class="modal-card stack ar-attachment-manager-card">
<div class="ar-attachment-manager-head"> <div class="ar-attachment-manager-head">
<h3 class="modal-title">${escapeHtml(titleText)}</h3> <h3 class="modal-title">${escapeHtml(titleText)}</h3>
<button class="icon-btn ar-attachment-manager-close" type="button" data-action="cancel" aria-label="Закрыть">${iconHtml('close')}</button> <button class="icon-btn ar-attachment-manager-close" type="button" data-action="cancel" aria-label="Закрыть">✕</button>
</div> </div>
<div class="tabs tabs--auto" role="radiogroup" aria-label="Способ загрузки"> <div class="tabs tabs--auto" role="radiogroup" aria-label="Способ загрузки">
<button class="tab-btn" role="radio" aria-checked="${turboMode}" type="button" data-action="switch-turbo">Turbo</button> <button class="tab-btn" role="radio" aria-checked="${turboMode}" type="button" data-action="switch-turbo">Turbo</button>
<button class="tab-btn" role="radio" aria-checked="${!turboMode}" type="button" data-action="switch-arweave">Свой кошелёк</button> <button class="tab-btn" role="radio" aria-checked="${!turboMode}" type="button" data-action="switch-arweave">Свой кошелёк Arweave</button>
</div> </div>
${turboMode ${turboMode
? ` ? `
+4 -17
View File
@@ -28,7 +28,6 @@ export function openChannelEditor({
submitLabel = 'Ответить', submitLabel = 'Ответить',
placeholder = 'Напишите ответ', placeholder = 'Напишите ответ',
context = null, context = null,
destination = '',
key = 'reply', key = 'reply',
extraControl = null, extraControl = null,
initialText = '', initialText = '',
@@ -39,7 +38,7 @@ export function openChannelEditor({
isActive = () => true, isActive = () => true,
} = {}) { } = {}) {
if (!state.session.isAuthorized) { if (!state.session.isAuthorized) {
openAuthRequiredModal({ title: 'Нужен аккаунт', text: 'Чтобы публиковать и отвечать, войдите или зарегистрируйтесь.' }); openAuthRequiredModal({ title: 'Войдите, чтобы написать', text: 'Для публикации и ответа нужен активный профиль.' });
return null; return null;
} }
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
@@ -78,15 +77,11 @@ export function openChannelEditor({
closeButton.type = 'button'; closeButton.type = 'button';
closeButton.className = 'icon-btn channel-editor__close'; closeButton.className = 'icon-btn channel-editor__close';
closeButton.setAttribute('aria-label', 'Закрыть редактор'); closeButton.setAttribute('aria-label', 'Закрыть редактор');
closeButton.innerHTML = iconHtml('close'); closeButton.textContent = '×';
const heading = document.createElement('h2'); const heading = document.createElement('h2');
heading.id = `${id}-title`; heading.id = `${id}-title`;
heading.textContent = title; heading.textContent = title;
// Закрытие не теряет текст: черновик хранится до отправки header.append(closeButton, heading, document.createElement('span'));
const draftMark = document.createElement('span');
draftMark.className = 'channel-editor__draft';
draftMark.textContent = 'Черновик';
header.append(closeButton, heading, draftMark);
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'channel-editor__body'; body.className = 'channel-editor__body';
@@ -135,17 +130,9 @@ export function openChannelEditor({
const author = document.createElement('div'); const author = document.createElement('div');
author.className = 'channel-editor__author'; author.className = 'channel-editor__author';
author.append(renderUserAvatar({ login: login || 'guest', className: 'avatar-plain', size: 'md' })); author.append(renderUserAvatar({ login: login || 'guest', className: 'avatar-plain', size: 'md' }));
const authorText = document.createElement('span');
authorText.className = 'channel-editor__author-text';
const authorName = document.createElement('strong'); const authorName = document.createElement('strong');
authorName.textContent = login || 'Гость'; authorName.textContent = login || 'Гость';
authorText.append(authorName); author.append(authorName);
if (destination) {
const where = document.createElement('small');
where.textContent = destination;
authorText.append(where);
}
author.append(authorText);
body.append(author); body.append(author);
body.append(textarea, attachmentsBox, error, clearButton); body.append(textarea, attachmentsBox, error, clearButton);
-165
View File
@@ -1,165 +0,0 @@
// Полноэкранный просмотр фото и видео из вложений: листание, «Скачать».
// Закрывается крестиком, Esc, смахиванием вниз или переходом назад.
import { iconHtml } from './ui-icon.js';
let activeClose = null;
function safeFileName(name, kind) {
const clean = String(name || '').trim().replace(/[\\/:*?"<>|]+/g, '_');
if (clean) return clean;
return kind === 'video' ? 'video.mp4' : 'image.jpg';
}
// Arweave отдаёт файл с другого адреса: атрибут download там не работает, поэтому скачиваем через blob.
async function downloadMedia(item) {
const name = safeFileName(item.name, item.kind);
try {
const response = await fetch(item.url);
if (!response.ok) throw new Error(String(response.status));
const blobUrl = URL.createObjectURL(await response.blob());
const link = document.createElement('a');
link.href = blobUrl;
link.download = name;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30000);
} catch {
window.open(item.url, '_blank', 'noopener');
}
}
/**
* items: [{ url, kind: 'image' | 'video', name }], index – с какого начать.
*/
export function openMediaViewer(items = [], index = 0) {
const list = items.filter((item) => item?.url && (item.kind === 'image' || item.kind === 'video'));
if (!list.length) return;
activeClose?.();
let current = Math.max(0, Math.min(list.length - 1, index));
const root = document.getElementById('modal-root') || document.body;
const overlay = document.createElement('div');
overlay.className = 'media-viewer';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Просмотр');
// Сверху – только «закрыть»; счётчик и «Скачать» – сразу под фото, по центру.
overlay.innerHTML = `
<div class="media-viewer__bar">
<button class="media-viewer__btn" type="button" data-action="close" aria-label="Закрыть">${iconHtml('close')}</button>
</div>
<div class="media-viewer__stage">
<div class="media-viewer__frame"></div>
<div class="media-viewer__controls">
<span class="media-viewer__counter"></span>
<button class="media-viewer__btn media-viewer__btn--round" type="button" data-action="download" aria-label="Скачать" title="Скачать">${iconHtml('download')}</button>
</div>
</div>
<button class="media-viewer__nav media-viewer__nav--prev" type="button" data-action="prev" aria-label="Предыдущее">${iconHtml('back')}</button>
<button class="media-viewer__nav media-viewer__nav--next" type="button" data-action="next" aria-label="Следующее">${iconHtml('open')}</button>
`;
const stage = overlay.querySelector('.media-viewer__stage');
const frame = overlay.querySelector('.media-viewer__frame');
const counter = overlay.querySelector('.media-viewer__counter');
const prevBtn = overlay.querySelector('[data-action="prev"]');
const nextBtn = overlay.querySelector('[data-action="next"]');
const render = () => {
stage.querySelectorAll('video').forEach((video) => video.pause());
const item = list[current];
let media;
if (item.kind === 'video') {
media = document.createElement('video');
media.controls = true;
media.autoplay = true;
media.playsInline = true;
} else {
media = document.createElement('img');
media.alt = item.name || '';
media.decoding = 'async';
}
media.className = 'media-viewer__media';
media.src = item.url;
frame.replaceChildren(media);
counter.textContent = list.length > 1 ? `${current + 1}/${list.length}` : '';
counter.hidden = list.length <= 1;
prevBtn.hidden = current <= 0;
nextBtn.hidden = current >= list.length - 1;
};
const go = (delta) => {
const next = current + delta;
if (next < 0 || next >= list.length) return;
current = next;
render();
};
const onKey = (event) => {
if (event.key === 'Escape') close();
else if (event.key === 'ArrowLeft') go(-1);
else if (event.key === 'ArrowRight') go(1);
};
const onPopState = () => close();
const close = () => {
if (activeClose !== close) return;
activeClose = null;
document.removeEventListener('keydown', onKey);
window.removeEventListener('popstate', onPopState);
stage.querySelectorAll('video').forEach((video) => video.pause());
overlay.remove();
};
let swiped = false;
overlay.addEventListener('click', (event) => {
// Клик, который браузер присылает после смахивания, не должен прятать панель или закрывать просмотр.
if (swiped) { swiped = false; return; }
const action = event.target.closest('[data-action]')?.dataset.action;
if (action === 'close') close();
else if (action === 'prev') go(-1);
else if (action === 'next') go(1);
else if (action === 'download') void downloadMedia(list[current]);
else if (event.target.closest('.media-viewer__media')?.tagName === 'IMG') overlay.classList.toggle('is-bare');
else if (event.target === stage || event.target === frame || event.target === overlay) close();
});
// Смахивание: вбок – листать, вниз – закрыть.
let startX = 0;
let startY = 0;
let tracking = false;
stage.addEventListener('pointerdown', (event) => {
if (event.target.tagName === 'VIDEO') return;
tracking = true;
startX = event.clientX;
startY = event.clientY;
});
stage.addEventListener('pointermove', (event) => {
if (!tracking) return;
const dy = event.clientY - startY;
if (dy > 0 && Math.abs(dy) > Math.abs(event.clientX - startX)) {
stage.style.transform = `translateY(${dy}px)`;
overlay.style.setProperty('--viewer-dim', String(Math.max(0.4, 1 - dy / 400)));
}
});
const endSwipe = (event) => {
if (!tracking) return;
tracking = false;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
swiped = Math.hypot(dx, dy) > 10;
stage.style.transform = '';
overlay.style.removeProperty('--viewer-dim');
if (dy > 90 && dy > Math.abs(dx)) close();
else if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) go(dx < 0 ? 1 : -1);
};
stage.addEventListener('pointerup', endSwipe);
stage.addEventListener('pointercancel', endSwipe);
document.addEventListener('keydown', onKey);
window.addEventListener('popstate', onPopState);
activeClose = close;
render();
root.append(overlay);
overlay.querySelector('[data-action="close"]')?.focus({ preventScroll: true });
}
+1 -2
View File
@@ -1,11 +1,10 @@
import { createDropdownMenu } from './dropdown-menu.js'; import { createDropdownMenu } from './dropdown-menu.js';
import { iconHtml } from './ui-icon.js';
export function attachMessageMenu(card, head, items) { export function attachMessageMenu(card, head, items) {
const trigger = document.createElement('button'); const trigger = document.createElement('button');
trigger.type = 'button'; trigger.type = 'button';
trigger.className = 'icon-btn channel-message-more'; trigger.className = 'icon-btn channel-message-more';
trigger.innerHTML = iconHtml('more'); trigger.textContent = '⋯';
trigger.setAttribute('aria-label', 'Действия сообщения'); trigger.setAttribute('aria-label', 'Действия сообщения');
head.append(trigger); head.append(trigger);
const menu = createDropdownMenu({ anchorEl: trigger, items }); const menu = createDropdownMenu({ anchorEl: trigger, items });
+1 -1
View File
@@ -16,7 +16,7 @@ function statusChip({ kind, label, value, active }) {
const n = Number(value || 0); const n = Number(value || 0);
return ` return `
<button type="button" class="pf-status${active ? ' is-active' : ''}" data-profile-list="${esc(kind)}" <button type="button" class="pf-status${active ? ' is-active' : ''}" data-profile-list="${esc(kind)}"
aria-label="${esc(label)}: подтверждений ${n}" title="Статус «${esc(label)}». Число – сколько людей его подтвердили. Нажмите, чтобы увидеть кто."> aria-label="${esc(label)}: подтверждений ${n}" title="Статус «${esc(label)}». Число — сколько людей его подтвердили. Нажмите, чтобы увидеть кто.">
<span class="pf-status-dot" aria-hidden="true"></span>${esc(label)}${n > 0 ? `<b>${n}</b>` : ''} <span class="pf-status-dot" aria-hidden="true"></span>${esc(label)}${n > 0 ? `<b>${n}</b>` : ''}
</button>`; </button>`;
} }
-63
View File
@@ -1,63 +0,0 @@
import { openSheet } from './sheet.js';
import { iconHtml } from './ui-icon.js';
import { showToast } from '../services/channels-ux.js';
// «Поделиться записью / комментарием»: что именно отправляется, ссылка и «Скопировать ссылку».
// Системное меню «Поделиться» — вторым действием, если браузер его умеет.
export function openShareSheet({ title = 'Поделиться', kicker = '', text = '', url = '' } = {}) {
return openSheet({
title,
className: 'share-sheet',
content: (sheet) => {
const wrap = document.createElement('div');
wrap.className = 'share-sheet__content';
const preview = document.createElement('div');
preview.className = 'share-sheet__preview';
const k = document.createElement('strong');
k.textContent = kicker;
const t = document.createElement('p');
t.textContent = text;
preview.append(k, t);
const label = document.createElement('label');
label.className = 'share-sheet__label';
label.textContent = 'Ссылка';
const field = document.createElement('input');
field.className = 'share-sheet__link';
field.readOnly = true;
field.value = url;
field.addEventListener('focus', () => field.select());
label.append(field);
const copy = document.createElement('button');
copy.type = 'button';
copy.className = 'primary-btn share-sheet__copy';
copy.innerHTML = `${iconHtml('copy')}<span>Скопировать ссылку</span>`;
copy.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(url);
showToast('Ссылка скопирована');
sheet.close();
} catch {
field.focus();
field.select();
showToast('Выделил ссылку – скопируйте её вручную', { kind: 'error' });
}
});
wrap.append(preview, label, copy);
if (navigator.share) {
const system = document.createElement('button');
system.type = 'button';
system.className = 'secondary-btn share-sheet__system';
system.innerHTML = `${iconHtml('share')}<span>Другое приложение…</span>`;
system.addEventListener('click', async () => {
try { await navigator.share({ title: kicker, text, url }); sheet.close(); } catch { /* отменили */ }
});
wrap.append(system);
}
return wrap;
},
});
}
-60
View File
@@ -1,60 +0,0 @@
import { iconHtml } from './ui-icon.js';
// Шторка снизу (на широком экране — окно по центру) с заголовком и «✕».
// Закрывается по «✕», Escape и тапу мимо; фокус возвращается туда, откуда открыли.
// Возвращает { root, body, footer, close }. content — Node или функция (sheet) => Node.
export function openSheet({ title = '', content = null, className = '', onClose = null } = {}) {
const opener = document.activeElement;
const modal = document.createElement('div');
modal.className = `modal sheet${className ? ` ${className}` : ''}`;
const card = document.createElement('div');
card.className = 'modal-card sheet__card';
card.setAttribute('role', 'dialog');
card.setAttribute('aria-modal', 'true');
const head = document.createElement('div');
head.className = 'sheet__head';
const heading = document.createElement('h2');
heading.className = 'modal-title sheet__title';
heading.textContent = title;
heading.id = `sheet-title-${Math.random().toString(36).slice(2, 8)}`;
card.setAttribute('aria-labelledby', heading.id);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'icon-btn sheet__close';
closeBtn.setAttribute('aria-label', 'Закрыть');
closeBtn.innerHTML = iconHtml('close');
head.append(heading, closeBtn);
const body = document.createElement('div');
body.className = 'sheet__body';
const footer = document.createElement('div');
footer.className = 'sheet__footer';
footer.hidden = true;
card.append(head, body, footer);
modal.append(card);
let closed = false;
const close = () => {
if (closed) return;
closed = true;
document.removeEventListener('keydown', onKeydown, true);
modal.remove();
opener?.focus?.();
onClose?.();
};
const onKeydown = (event) => {
if (event.key === 'Escape') { event.stopPropagation(); close(); }
};
modal.addEventListener('click', (event) => { if (event.target === modal) close(); });
closeBtn.addEventListener('click', close);
document.addEventListener('keydown', onKeydown, true);
const sheet = { root: modal, body, footer, close };
const node = typeof content === 'function' ? content(sheet) : content;
if (node instanceof Node) body.append(node);
if (footer.childElementCount) footer.hidden = false;
document.body.append(modal);
(card.querySelector('[autofocus]') || closeBtn).focus();
return sheet;
}
-18
View File
@@ -1,18 +0,0 @@
// Надпись «СИЯНИЕ» вразрядку: Tenor Sans (OFL) в контурах — шрифт не скачивается.
// Цвет — currentColor, поэтому надпись берёт цвет текста темы.
const NS = 'http://www.w3.org/2000/svg';
const WIDTH = 7336;
const PATH = 'M721 -129Q619 -33 479 -33Q401 -33 341 -60Q281 -87 241 -132Q201 -177 180.5 -236Q160 -295 160 -359Q160 -427 181.5 -483.5Q203 -540 242 -581Q281 -622 334.5 -645Q388 -668 451 -668Q494 -668 529.5 -658Q565 -648 592.5 -632Q620 -616 637.5 -595.5Q655 -575 662 -554H736Q721 -588 697.5 -617Q674 -646 639 -667Q604 -688 557 -700Q510 -712 450 -712Q363 -712 291.5 -687Q220 -662 169 -615.5Q118 -569 90 -503.5Q62 -438 62 -357Q62 -287 84.5 -220.5Q107 -154 154 -102.5Q201 -51 274 -19.5Q347 12 448 12Q512 12 562.5 0Q613 -12 650.5 -29.5Q688 -47 713 -67Q738 -87 751 -102ZM1542 -82V-700H1451V0H1542L1957 -625V0H2048V-700H1957ZM3316 -700V0H3225V-309H3181Q3153 -309 3134.5 -304Q3116 -299 3101 -287Q3086 -275 3073 -256Q3060 -237 3042 -209L2963 -92Q2948 -69 2933.5 -50Q2919 -31 2902 -17.5Q2885 -4 2863.5 4Q2842 12 2812 12Q2790 12 2776 10Q2762 8 2748 0V-37Q2753 -36 2758 -35.5Q2763 -35 2768 -35Q2789 -35 2805 -41Q2821 -47 2835 -58.5Q2849 -70 2862 -86.5Q2875 -103 2889 -124Q2931 -185 2954 -221.5Q2977 -258 2990.5 -278Q3004 -298 3012.5 -307Q3021 -316 3033 -322L3043 -327V-331Q2995 -334 2960.5 -352Q2926 -370 2904 -397Q2882 -424 2872 -455.5Q2862 -487 2862 -517Q2862 -544 2872.5 -576Q2883 -608 2908.5 -635.5Q2934 -663 2978 -681.5Q3022 -700 3089 -700ZM3225 -656H3124Q3086 -656 3055.5 -645Q3025 -634 3003 -614.5Q2981 -595 2969.5 -568Q2958 -541 2958 -509Q2958 -482 2968.5 -454.5Q2979 -427 3000 -404.5Q3021 -382 3051.5 -368Q3082 -354 3122 -354H3225ZM4197 -348H4616V0H4707V-700H4616V-394H4197V-700H4106V0H4197ZM5588 -82V-700H5497V0H5588L6003 -625V0H6094V-700H6003ZM6884 -700V0H7336V-55H6975V-346H7285V-394H6975V-645H7336V-700Z';
export function createShineWordmark({ className = '' } = {}) {
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', `52 -722 ${WIDTH - 42} 744`);
svg.setAttribute('class', `shine-wordmark ${className}`.trim());
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', 'Сияние');
const path = document.createElementNS(NS, 'path');
path.setAttribute('d', PATH);
path.setAttribute('fill', 'currentColor');
svg.append(path);
return svg;
}
+6 -6
View File
@@ -96,22 +96,22 @@ function navigateWithGuestRules(pageId, navigate) {
} }
if (pageId === 'messages-list') { if (pageId === 'messages-list') {
openAuthRequiredModal({ openAuthRequiredModal({
title: 'Нужен аккаунт', title: 'Личные сообщения недоступны',
text: 'Чтобы писать личные сообщения, войдите или зарегистрируйтесь.', text: 'Вы не авторизованы. Для личных сообщений сначала войдите в систему.',
}); });
return; return;
} }
if (pageId === 'profile-view') { if (pageId === 'profile-view') {
openAuthRequiredModal({ openAuthRequiredModal({
title: 'Нужен аккаунт', title: 'Профиль недоступен',
text: 'Чтобы открыть профиль, войдите или зарегистрируйтесь.', text: 'Вы не авторизованы. Для профиля сначала войдите в систему.',
}); });
return; return;
} }
if (pageId === 'notifications-view') { if (pageId === 'notifications-view') {
openAuthRequiredModal({ openAuthRequiredModal({
title: 'Нужен аккаунт', title: 'Уведомления недоступны',
text: 'Чтобы получать уведомления, войдите или зарегистрируйтесь.', text: 'Вы не авторизованы. Для уведомлений сначала войдите в систему.',
}); });
return; return;
} }
-3
View File
@@ -18,9 +18,6 @@ function createActionButton(action = {}, cleanupFns) {
button.append(action.iconNode); button.append(action.iconNode);
} else if (action.icon) { } else if (action.icon) {
button.innerHTML = iconHtml(action.icon); button.innerHTML = iconHtml(action.icon);
} else if (action.label === '⋮' || action.label === '⋯') {
// Меню экрана везде рисуется одной иконкой «⋯»
button.innerHTML = iconHtml('more');
} else { } else {
button.textContent = String(action.label ?? ''); button.textContent = String(action.label ?? '');
} }
+5 -28
View File
@@ -1,32 +1,11 @@
const paths = { const paths = {
heart: '<path d="M12 21S2.3 15.5 2.3 8.7C2.3 3.6 8.1 1.9 12 6.5c3.9-4.6 9.7-2.9 9.7 2.2C21.7 15.5 12 21 12 21Z"/>', heart: '<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.6a5.5 5.5 0 0 0 0-7.8Z"/>',
message: '<path d="M5.2 2.35h13.6a2.7 2.7 0 0 1 2.7 2.7v9.7a2.7 2.7 0 0 1-2.7 2.7h-7.1L7.2 21v-3.55h-2a2.7 2.7 0 0 1-2.7-2.7v-9.7a2.7 2.7 0 0 1 2.7-2.7Z"/>', message: '<path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2v-9.5A8.5 8.5 0 0 1 10.5 4H13a8 8 0 0 1 8 7.5Z"/>',
more: '<circle cx="4" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="20" cy="12" r="1.6" fill="currentColor" stroke="none"/>',
forward: '<path d="m14.4 3.1 7.2 7.1-7.2 6.6v-4.4C8.5 12.4 5 15.1 2.4 20c.4-7.6 4.4-11.7 12-12.4Z"/>',
download: '<path d="M12 4v11M7 10.5l5 5 5-5M5 20h14"/>',
reply: '<path d="M9 7 4 12l5 5"/><path d="M4 12h9a7 7 0 0 1 7 7"/>',
copy: '<rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5.5M12 7.6v.1"/>',
trash: '<path d="M4 7h16M9.5 7V4.8h5V7M6.5 7l.9 12.2a1.9 1.9 0 0 0 1.9 1.8h5.4a1.9 1.9 0 0 0 1.9-1.8L17.5 7M10 11v6M14 11v6"/>',
'bell-off': '<path d="M8.5 4.2A6 6 0 0 1 18 9c0 4.3 1.1 6.2 2 7.2M18 17H3c0-2 3-2 3-9M10 21h4M3 3l18 18"/>',
phone: '<path d="M5 3.5h3.2l1.6 4.2-2.1 1.4a11 11 0 0 0 5.2 5.2l1.4-2.1 4.2 1.6V17a2.5 2.5 0 0 1-2.7 2.5A16 16 0 0 1 2.5 6.2 2.5 2.5 0 0 1 5 3.5Z"/>',
video: '<rect x="2.5" y="6" width="13" height="12" rx="2.5"/><path d="m15.5 10.5 6-3.5v10l-6-3.5"/>',
emoji: '<circle cx="12" cy="12" r="9"/><path d="M8.3 14.2a4.6 4.6 0 0 0 7.4 0"/><circle cx="9" cy="9.8" r=".9" fill="currentColor" stroke="none"/><circle cx="15" cy="9.8" r=".9" fill="currentColor" stroke="none"/>',
send: '<path d="M12 19V5M5.5 11.5 12 5l6.5 6.5"/>',
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/>',
image: '<rect x="3" y="4.5" width="18" height="15" rx="2.5"/><circle cx="8.5" cy="9.5" r="1.6"/><path d="m21 16-4.7-4.7L7 20.5"/>',
list: '<path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r=".9" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r=".9" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r=".9" fill="currentColor" stroke="none"/>',
doc: '<path d="M6.5 3h7.5L19 8v11.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 19.5v-15A1.5 1.5 0 0 1 6.5 3Z"/><path d="M14 3v5h5M8.5 13h7M8.5 16.5h5"/>',
close: '<path d="M6 6l12 12M18 6 6 18"/>',
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
alert: '<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.4v.1"/>',
'check-double': '<path d="m2.5 12.5 4 4L15 8"/><path d="m10.5 16 1 .5L21 8"/>',
compose: '<path d="M13 4H6.5A2.5 2.5 0 0 0 4 6.5v11A2.5 2.5 0 0 0 6.5 20h11a2.5 2.5 0 0 0 2.5-2.5V11"/><path d="m18.5 3.8 1.7 1.7a1.3 1.3 0 0 1 0 1.8L12.4 15H9v-3.4l7.7-7.8a1.3 1.3 0 0 1 1.8 0Z"/>',
channels: '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>', channels: '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
network: '<circle cx="12" cy="5" r="3"/><circle cx="5" cy="18" r="3"/><circle cx="19" cy="18" r="3"/><path d="m10 8-4 7m8-7 4 7M8 18h8"/>', network: '<circle cx="12" cy="5" r="3"/><circle cx="5" cy="18" r="3"/><circle cx="19" cy="18" r="3"/><path d="m10 8-4 7m8-7 4 7M8 18h8"/>',
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>', bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>',
profile: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>', profile: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
share: '<path d="m14.4 3.1 7.2 7.1-7.2 6.6v-4.4C8.5 12.4 5 15.1 2.4 20c.4-7.6 4.4-11.7 12-12.4Z"/>', share: '<path d="m8 12 8-8M9 4h7v7M20 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>', search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3.2-3.2a4.5 4.5 0 0 0-6.4-6.4L12 5.6"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3.2 3.2a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/>', link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3.2-3.2a4.5 4.5 0 0 0-6.4-6.4L12 5.6"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3.2 3.2a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/>',
gift: '<rect x="3" y="8" width="18" height="5" rx="1"/><path d="M5 13v7a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7M12 8v13M12 8S10.5 3 8 3.5 7 8 12 8Zm0 0s1.5-5 4-4.5S17 8 12 8Z"/>', gift: '<rect x="3" y="8" width="18" height="5" rx="1"/><path d="M5 13v7a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7M12 8v13M12 8S10.5 3 8 3.5 7 8 12 8Zm0 0s1.5-5 4-4.5S17 8 12 8Z"/>',
@@ -41,11 +20,9 @@ const paths = {
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>', settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
'user-plus': '<circle cx="10" cy="8" r="3.5"/><path d="M3.5 20a6.5 6.5 0 0 1 13 0M19 8v6M16 11h6"/>', 'user-plus': '<circle cx="10" cy="8" r="3.5"/><path d="M3.5 20a6.5 6.5 0 0 1 13 0M19 8v6M16 11h6"/>',
refresh: '<path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v4.4h-4.4"/>', refresh: '<path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v4.4h-4.4"/>',
eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="3"/>',
eyeOff: '<path d="M10.2 5.7A9.6 9.6 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a16.4 16.4 0 0 1-2.6 3.4M6.4 7A16.2 16.2 0 0 0 2.5 12S6 18.5 12 18.5a9 9 0 0 0 4.9-1.4"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2M3.5 3.5l17 17"/>',
clip: '<path d="m20.5 11.5-8.2 8.2a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/>', clip: '<path d="m20.5 11.5-8.2 8.2a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/>',
}; };
export function iconHtml(name, filled = false, className = '') { export function iconHtml(name, filled = false) {
return `<svg${className ? ` class="${className}"` : ''} viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.message}</svg>`; return `<svg viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.message}</svg>`;
} }
+22 -21
View File
@@ -177,14 +177,14 @@ function createPasswordModal() {
<div class="stack" style="gap:0.45rem;"> <div class="stack" style="gap:0.45rem;">
<label class="checkbox-row"> <label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="once" checked /> <input type="radio" name="access-servers-key-mode" value="once" checked />
<span>Использовать главный ключ только сейчас</span> <span>Использовать blockchain key только сейчас</span>
</label> </label>
<label class="checkbox-row"> <label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="save" /> <input type="radio" name="access-servers-key-mode" value="save" />
<span>Сохранить главный ключ на этом устройстве</span> <span>Сохранить blockchain key на этом устройстве</span>
</label> </label>
</div> </div>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p> <p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, blockchain key попадёт в зашифрованный контейнер устройства.</p>
<div class="form-actions-grid"> <div class="form-actions-grid">
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button> <button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button>
<button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button> <button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button>
@@ -247,7 +247,7 @@ function createPasswordModal() {
const mode = root.querySelector('input[name="access-servers-key-mode"]:checked'); const mode = root.querySelector('input[name="access-servers-key-mode"]:checked');
close({ close({
password, password,
saveRoot: mode instanceof HTMLInputElement && mode.value === 'save', saveBlockchain: mode instanceof HTMLInputElement && mode.value === 'save',
}); });
}); });
window.setTimeout(() => inputEl.focus(), 0); window.setTimeout(() => inputEl.focus(), 0);
@@ -482,48 +482,49 @@ export function render({navigate, chrome}) {
saved = null; saved = null;
} }
const savedRoot = String(saved?.rootKey || '').trim(); const savedBlockchain = String(saved?.blockchainKey || '').trim();
const savedClient = String(saved?.clientKey || '').trim(); const savedClient = String(saved?.clientKey || '').trim();
if (savedRoot && savedClient) { if (savedBlockchain && savedClient) {
return { return {
rootPrivatePkcs8B64: savedRoot, blockchainPrivatePkcs8B64: savedBlockchain,
clientPrivatePkcs8B64: savedClient, clientPrivatePkcs8B64: savedClient,
clientAddress: await clientAddressFromPrivatePkcs8(savedClient), payerAddress: await clientAddressFromPrivatePkcs8(savedBlockchain),
}; };
} }
const passwordResult = await passwordModal?.open({ const passwordResult = await passwordModal?.open({
title: 'Нужен пароль для обновления серверов доступа', title: 'Нужен пароль для обновления серверов доступа',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.', text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
note: savedClient note: savedClient
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.' ? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен blockchain key.'
: 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.', : 'На устройстве не хватает blockchain key и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
}); });
if (!passwordResult) { if (!passwordResult) {
throw new Error('Операция отменена пользователем.'); throw new Error('Операция отменена пользователем.');
} }
const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password); const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password);
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64); const derivedBlockchainPublic = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64); const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) { const activeBlockchain = currentPda.forks?.at(-1)?.blockchainKey;
throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.'); if (!activeBlockchain || !equalBytes(derivedBlockchainPublic, activeBlockchain)) {
throw new Error('Пароль не подходит: blockchain key не совпал с активным fork аккаунта.');
} }
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) { if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.'); throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
} }
if (passwordResult.saveRoot) { if (passwordResult.saveBlockchain) {
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, { await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
saveRoot: true, saveRoot: false,
saveBlockchain: false, saveBlockchain: true,
}); });
} }
return { return {
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64, blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64), payerAddress: clientAddressFromPublicB64(keyBundle.blockchainPair.publicKeyB64),
}; };
}; };
@@ -550,7 +551,7 @@ export function render({navigate, chrome}) {
const tx = await updateShineUserPdaOnSolana({ const tx = await updateShineUserPdaOnSolana({
login: sessionLogin, login: sessionLogin,
solanaEndpoint, solanaEndpoint,
rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64, blockchainPrivatePkcs8B64: signingMaterial.blockchainPrivatePkcs8B64,
clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64, clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64,
accessServers: normalizedList, accessServers: normalizedList,
}); });
@@ -562,7 +563,7 @@ export function render({navigate, chrome}) {
refreshAddButton(); refreshAddButton();
} catch (error) { } catch (error) {
if (isInsufficientFundsForRentError(error)) { if (isInsufficientFundsForRentError(error)) {
showTopupRequiredStatus(target, signingMaterial?.clientAddress); showTopupRequiredStatus(target, signingMaterial?.payerAddress);
} else { } else {
target.textContent = error?.message || 'Не удалось обновить сервер доступа.'; target.textContent = error?.message || 'Не удалось обновить сервер доступа.';
} }
+2 -2
View File
@@ -17,7 +17,7 @@ function createToggleRow({ id, title, hint, checked }) {
<strong class="settings-feature-toggle-title">${title}</strong> <strong class="settings-feature-toggle-title">${title}</strong>
<span class="settings-feature-toggle-hint">${hint}</span> <span class="settings-feature-toggle-hint">${hint}</span>
</span> </span>
<input class="switch" id="${id}" type="checkbox" ${checked ? 'checked' : ''} /> <input class="settings-feature-switch" id="${id}" type="checkbox" ${checked ? 'checked' : ''} />
`; `;
return label; return label;
} }
@@ -34,7 +34,7 @@ export function render({ navigate, chrome }) {
const intro = document.createElement('div'); const intro = document.createElement('div');
intro.className = 'settings-intro'; intro.className = 'settings-intro';
intro.innerHTML = ` intro.innerHTML = `
<p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по заголовку «Настройки».</p> <p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по логотипу в настройках.</p>
`; `;
const developerRow = createToggleRow({ const developerRow = createToggleRow({
+1 -1
View File
@@ -119,7 +119,7 @@ export function render({navigate, chrome}) {
back: { onClick: () => navigate('settings-view') }, back: { onClick: () => navigate('settings-view') },
actions: [ actions: [
{ {
icon: 'plus', label: '+',
title: 'Добавить файл', title: 'Добавить файл',
ariaLabel: 'Добавить файл', ariaLabel: 'Добавить файл',
className: 'arweave-uploads-add', className: 'arweave-uploads-add',
+14 -37
View File
@@ -14,7 +14,7 @@ import {
formatSol, formatSol,
getBalanceSol, getBalanceSol,
getSolanaWalletFromStoredSecret, getSolanaWalletFromStoredSecret,
getWalletFromStoredClientKey, getWalletFromStoredBlockchainKey,
getWalletFromStoredRootKey, getWalletFromStoredRootKey,
solanaAddressFromPublicKeyBase64, solanaAddressFromPublicKeyBase64,
transferSol, transferSol,
@@ -138,9 +138,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
if (!root) return; if (!root) return;
const recipientChoices = [ const recipientChoices = [
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'), publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
].filter(Boolean); ].filter(Boolean);
root.innerHTML = ` root.innerHTML = `
@@ -163,18 +161,15 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
<select class="select" id="channel-support-recipient-key" ${recipientChoices.length ? '' : 'disabled'}> <select class="select" id="channel-support-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')} ${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
</select> </select>
<div class="channel-support-address" id="channel-support-recipient-address">–</div> <div class="channel-support-address" id="channel-support-recipient-address">—</div>
<label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label> <label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label>
<select class="select" id="channel-support-sender-key"> <div class="channel-support-address" id="channel-support-sender-key">Blockchain key</div>
<option value="client-key">Client key</option> <div class="channel-support-address" id="channel-support-sender-address">—</div>
<option value="root-key">Root key</option>
</select>
<div class="channel-support-address" id="channel-support-sender-address">–</div>
<div class="channel-support-balance-row"> <div class="channel-support-balance-row">
<span class="meta-muted">Баланс</span> <span class="meta-muted">Баланс</span>
<strong id="channel-support-balance">–</strong> <strong id="channel-support-balance">—</strong>
<button class="secondary-btn channel-support-refresh" id="channel-support-refresh" type="button">Обновить</button> <button class="secondary-btn channel-support-refresh" id="channel-support-refresh" type="button">Обновить</button>
</div> </div>
@@ -247,31 +242,13 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
}; };
const resolveSenderWallet = async () => { const resolveSenderWallet = async () => {
const keyId = String(senderSelect?.value || 'client-key'); const keyId = 'blockchain-key';
if (walletCache.has(keyId)) return walletCache.get(keyId); if (walletCache.has(keyId)) return walletCache.get(keyId);
const login = String(state.session.login || '').trim(); const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim(); const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.'); if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
let wallet;
if (keyId === 'root-key') {
try {
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
} catch {
const password = window.prompt(
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
'',
);
if (password == null) throw new Error('Операция отменена.');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
}
} else {
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
}
walletCache.set(keyId, wallet); walletCache.set(keyId, wallet);
return wallet; return wallet;
@@ -281,7 +258,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
if (busy || closed) return; if (busy || closed) return;
setBusy(true); setBusy(true);
setStatus('Загрузка баланса…'); setStatus('Загрузка баланса…');
if (balanceEl) balanceEl.textContent = '–'; if (balanceEl) balanceEl.textContent = '—';
try { try {
const wallet = await resolveSenderWallet(); const wallet = await resolveSenderWallet();
if (closed) return; if (closed) return;
@@ -295,7 +272,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
setStatus(''); setStatus('');
} catch (error) { } catch (error) {
if (closed) return; if (closed) return;
if (senderAddressEl) senderAddressEl.textContent = '–'; if (senderAddressEl) senderAddressEl.textContent = '—';
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error'); setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
} finally { } finally {
if (!closed) setBusy(false); if (!closed) setBusy(false);
@@ -304,8 +281,8 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
recipientSelect?.addEventListener('change', updateRecipientAddress); recipientSelect?.addEventListener('change', updateRecipientAddress);
senderSelect?.addEventListener('change', () => { senderSelect?.addEventListener('change', () => {
if (senderAddressEl) senderAddressEl.textContent = '–'; if (senderAddressEl) senderAddressEl.textContent = '—';
if (balanceEl) balanceEl.textContent = '–'; if (balanceEl) balanceEl.textContent = '—';
setStatus(''); setStatus('');
void refreshBalance(); void refreshBalance();
}); });
@@ -494,13 +471,13 @@ export function render({ navigate, route, chrome }) {
<div class="channel-about-hero"> <div class="channel-about-hero">
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div> <div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
<h2 class="channel-about-title">${escapeHtml(cleanName)}</h2> <h2 class="channel-about-title">${escapeHtml(cleanName)}</h2>
<div class="channel-about-subline">@${escapeHtml(channelName || ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}</div> <div class="channel-about-subline">@${escapeHtml(ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}</div>
</div> </div>
<div class="channel-about-tiles"> <div class="channel-about-tiles">
${cameFromChannel ? '' : `<button class="channel-about-tile" id="channel-about-open" type="button">${iconHtml('open')}<span>Открыть</span></button>`} ${cameFromChannel ? '' : `<button class="channel-about-tile" id="channel-about-open" type="button">${iconHtml('open')}<span>Открыть</span></button>`}
${subscriptionTile} ${subscriptionTile}
${isOwnChannel ? '' : `<button class="channel-about-tile" id="channel-about-support" type="button">${iconHtml('gift')}<span>Поддержать автора</span></button>`} <button class="channel-about-tile" id="channel-about-support" type="button">${iconHtml('gift')}<span>Донат</span></button>
</div> </div>
<div class="channel-about-list"> <div class="channel-about-list">
@@ -521,7 +498,7 @@ export function render({ navigate, route, chrome }) {
<span class="channel-about-row-label">Ссылка</span> <span class="channel-about-row-label">Ссылка</span>
<span class="channel-about-link">${escapeHtml(shortLink)}</span> <span class="channel-about-link">${escapeHtml(shortLink)}</span>
</span> </span>
<span class="channel-about-row-chevron" aria-hidden="true">${iconHtml('copy')}</span> <span class="channel-about-row-chevron" aria-hidden="true">${iconHtml('link')}</span>
</button> </button>
</div> </div>
`; `;
+11 -34
View File
@@ -6,7 +6,7 @@ import {
formatSol, formatSol,
getBalanceSol, getBalanceSol,
getSolanaWalletFromStoredSecret, getSolanaWalletFromStoredSecret,
getWalletFromStoredClientKey, getWalletFromStoredBlockchainKey,
getWalletFromStoredRootKey, getWalletFromStoredRootKey,
solanaAddressFromPublicKeyBase64, solanaAddressFromPublicKeyBase64,
transferSol, transferSol,
@@ -140,9 +140,7 @@ export function render({ navigate, route, chrome }) {
const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim(); const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
const channelName = String(channel?.channelName || selector?.channelName || '').trim(); const channelName = String(channel?.channelName || selector?.channelName || '').trim();
const recipientChoices = [ const recipientChoices = [
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'), publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
].filter(Boolean); ].filter(Boolean);
content.innerHTML = ` content.innerHTML = `
@@ -164,20 +162,17 @@ export function render({ navigate, route, chrome }) {
<select class="select" id="channel-donate-recipient-key" ${recipientChoices.length ? '' : 'disabled'}> <select class="select" id="channel-donate-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')} ${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
</select> </select>
<div class="channel-donate-address" id="channel-donate-recipient-address">–</div> <div class="channel-donate-address" id="channel-donate-recipient-address">—</div>
</section> </section>
<section class="channel-donate-section"> <section class="channel-donate-section">
<label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label> <label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label>
<select class="select" id="channel-donate-sender-key"> <div class="channel-donate-address" id="channel-donate-sender-key">Blockchain key</div>
<option value="client-key">Client key</option> <div class="channel-donate-address" id="channel-donate-sender-address">—</div>
<option value="root-key">Root key</option>
</select>
<div class="channel-donate-address" id="channel-donate-sender-address">–</div>
<div class="channel-donate-balance-row"> <div class="channel-donate-balance-row">
<span class="meta-muted">Баланс</span> <span class="meta-muted">Баланс</span>
<strong id="channel-donate-balance">–</strong> <strong id="channel-donate-balance">—</strong>
<button class="secondary-btn channel-donate-refresh" id="channel-donate-refresh" type="button">Обновить</button> <button class="secondary-btn channel-donate-refresh" id="channel-donate-refresh" type="button">Обновить</button>
</div> </div>
</section> </section>
@@ -233,31 +228,13 @@ export function render({ navigate, route, chrome }) {
}; };
const resolveSenderWallet = async () => { const resolveSenderWallet = async () => {
const keyId = String(senderSelect?.value || 'client-key'); const keyId = 'blockchain-key';
if (walletCache.has(keyId)) return walletCache.get(keyId); if (walletCache.has(keyId)) return walletCache.get(keyId);
const login = String(state.session.login || '').trim(); const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim(); const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.'); if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
let wallet;
if (keyId === 'root-key') {
try {
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
} catch {
const password = window.prompt(
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
'',
);
if (password == null) throw new Error('Операция отменена.');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
}
} else {
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
}
walletCache.set(keyId, wallet); walletCache.set(keyId, wallet);
return wallet; return wallet;
@@ -267,7 +244,7 @@ export function render({ navigate, route, chrome }) {
if (busy || disposed) return; if (busy || disposed) return;
setBusy(true); setBusy(true);
setStatus('Загрузка баланса…'); setStatus('Загрузка баланса…');
if (balanceEl) balanceEl.textContent = '–'; if (balanceEl) balanceEl.textContent = '—';
try { try {
const wallet = await resolveSenderWallet(); const wallet = await resolveSenderWallet();
if (disposed) return; if (disposed) return;
@@ -281,7 +258,7 @@ export function render({ navigate, route, chrome }) {
setStatus(''); setStatus('');
} catch (error) { } catch (error) {
if (disposed) return; if (disposed) return;
if (senderAddressEl) senderAddressEl.textContent = '–'; if (senderAddressEl) senderAddressEl.textContent = '—';
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error'); setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
} finally { } finally {
if (!disposed) setBusy(false); if (!disposed) setBusy(false);
@@ -290,8 +267,8 @@ export function render({ navigate, route, chrome }) {
recipientSelect?.addEventListener('change', updateRecipientAddress); recipientSelect?.addEventListener('change', updateRecipientAddress);
senderSelect?.addEventListener('change', () => { senderSelect?.addEventListener('change', () => {
if (senderAddressEl) senderAddressEl.textContent = '–'; if (senderAddressEl) senderAddressEl.textContent = '—';
if (balanceEl) balanceEl.textContent = '–'; if (balanceEl) balanceEl.textContent = '—';
setStatus(''); setStatus('');
void refreshBalance(); void refreshBalance();
}); });
+67 -411
View File
@@ -9,7 +9,6 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import { import {
animatePress, animatePress,
createSkeletonCard, createSkeletonCard,
formatPostStamp,
formatRelativeTime, formatRelativeTime,
longPressFeel, longPressFeel,
shareOrCopyLink, shareOrCopyLink,
@@ -19,9 +18,6 @@ import {
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js'; import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { openChannelEditor } from '../components/channel-editor.js'; import { openChannelEditor } from '../components/channel-editor.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createEmojiPicker } from '../components/emoji-picker.js';
import { openShareSheet } from '../components/share-sheet.js';
import { import {
composeMessageWithAttachments, composeMessageWithAttachments,
createAttachmentCarouselElement, createAttachmentCarouselElement,
@@ -470,7 +466,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code> <span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code> <span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code> <span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '–')}</code> <span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}</code>
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code> <span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
<span>Подпись</span><code>${escapeHtml(details.signature)}</code> <span>Подпись</span><code>${escapeHtml(details.signature)}</code>
</div> </div>
@@ -627,7 +623,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
const text = String(item?.text || '').trim() || 'удалено'; const text = String(item?.text || '').trim() || 'удалено';
row.innerHTML = ` row.innerHTML = `
<strong>Версия ${index + 1}</strong> <strong>Версия ${index + 1}</strong>
<div class="meta-muted">${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '–'}</div> <div class="meta-muted">${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '—'}</div>
<p class="channel-message-body">${text}</p> <p class="channel-message-body">${text}</p>
`; `;
list.append(row); list.append(row);
@@ -719,7 +715,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
} }
const timestamp = document.createElement('div'); const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time'; timestamp.className = 'channel-message-time';
timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '–'; timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '—';
if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU'); if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
authorBlock.append(title, timestamp); authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock); authorTile.append(avatar, authorBlock);
@@ -965,176 +961,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
return card; return card;
} }
// ===== Обсуждение в раскладке V4: контекст записи, плоский список комментариев, строка ввода =====
// Дерево ответов → плоский список в порядке чтения; у каждого — на что он отвечает
function flattenDescendants(items, parent = null, out = []) {
(Array.isArray(items) ? items : []).forEach((branch) => {
if (!branch?.node) return;
out.push({ node: branch.node, parent });
flattenDescendants(branch.children, branch.node, out);
});
return out;
}
function nodeKey(node) {
return messageRefKey(buildTargetFromNode(node));
}
// Исходная запись сверху: две строки, «Развернуть» — только если текст правда обрезан
function renderThreadContext(node, { onOpenPost }) {
const parsed = parseMessageAttachments(resolveNodeText(node));
const box = document.createElement('section');
box.className = 'thread-context';
const text = document.createElement('p');
text.className = 'thread-context__text';
text.textContent = parsed.text || (parsed.attachments.length ? 'Запись с вложением' : '');
const meta = document.createElement('div');
meta.className = 'thread-context__meta';
const time = document.createElement('span');
time.textContent = formatPostStamp(node?.createdAtMs);
const expand = document.createElement('button');
expand.type = 'button';
expand.className = 'text-btn thread-context__expand';
expand.textContent = 'Развернуть';
expand.hidden = true;
expand.addEventListener('click', () => {
const open = box.classList.toggle('is-expanded');
expand.textContent = open ? 'Свернуть' : 'Развернуть';
});
const toPost = document.createElement('button');
toPost.type = 'button';
toPost.className = 'text-btn thread-context__open';
toPost.innerHTML = `<span>${parsed.attachments.length ? 'Запись с фото' : 'К записи'}</span>${iconHtml('open')}`;
toPost.addEventListener('click', onOpenPost);
meta.append(time, expand, toPost);
box.append(text, meta);
// обрезан ли текст — видно только после вставки в документ
requestAnimationFrame(() => { expand.hidden = text.scrollHeight <= text.clientHeight + 1; });
return box;
}
function renderComment({ node, parent, focus, channelOwner, handlers }) {
const target = buildTargetFromNode(node);
const key = messageRefKey(target);
const author = String(node?.authorLogin || 'автор');
const parsed = parseMessageAttachments(resolveNodeText(node));
const versions = Array.isArray(node?.versions) ? node.versions : [];
const isOwn = Boolean(state.session.login) && author.toLowerCase() === String(state.session.login).toLowerCase();
const item = document.createElement('article');
item.className = 'thread-comment channel-message-card';
if (key) item.dataset.messageKey = key;
const person = document.createElement('div');
person.className = 'thread-comment__person';
const who = document.createElement('button');
who.type = 'button';
who.className = 'thread-comment__who';
who.append(renderUserAvatar({ login: author, size: 'xs', className: 'thread-comment__avatar' }));
const name = document.createElement('span');
name.className = 'thread-comment__name';
name.textContent = author;
who.append(name);
who.addEventListener('click', () => handlers.navigate(makeProfileRoute(author)));
person.append(who);
if (channelOwner && author.toLowerCase() === channelOwner.toLowerCase()) {
const badge = document.createElement('span');
badge.className = 'thread-comment__badge';
badge.textContent = 'Автор';
person.append(badge);
}
const time = document.createElement('time');
time.textContent = formatPostStamp(node?.createdAtMs);
if (node?.createdAtMs) time.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
person.append(time);
item.append(person);
// Ответ на другой комментарий — цитата в две строки; нажатие ведёт к оригиналу
if (parent && parent !== focus) {
const quote = document.createElement('button');
quote.type = 'button';
quote.className = 'thread-quote';
const qa = document.createElement('strong');
qa.textContent = String(parent.authorLogin || '');
const qt = document.createElement('span');
qt.textContent = parseMessageAttachments(resolveNodeText(parent)).text || 'Вложение';
quote.append(qa, qt);
quote.setAttribute('aria-label', `Ответ на комментарий ${parent.authorLogin || ''}. Перейти к нему`);
quote.addEventListener('click', () => handlers.jumpTo(nodeKey(parent), key));
item.append(quote);
}
if (parsed.attachments.length) {
item.append(createAttachmentCarouselElement(parsed.attachments, { gateway: state.entrySettings.arweaveServer, messageTimestampMs: node?.createdAtMs }));
}
const text = document.createElement('p');
text.className = 'thread-comment__text';
text.textContent = parsed.text;
item.append(text);
const actions = document.createElement('div');
actions.className = 'thread-comment__actions';
const reply = document.createElement('button');
reply.type = 'button';
reply.className = 'thread-comment__reply';
reply.textContent = 'Ответить';
reply.addEventListener('click', () => handlers.setReplyTo(node));
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
const liked = getMessageReactionState(target) === 'liked';
const likes = Number(node?.likesCount || 0);
const like = document.createElement('button');
like.type = 'button';
like.className = `channel-action-item thread-comment__like${liked ? ' is-liked' : ''}`;
like.innerHTML = `${iconHtml('heart', liked)}<span>${likes || ''}</span>`;
like.setAttribute('aria-pressed', String(liked));
like.setAttribute('aria-label', `${liked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
like.addEventListener('click', async () => {
like.disabled = true;
try { await handlers.onToggleLike(target, liked ? 'unlike' : 'like'); }
catch (error) { handlers.onActionError(error, liked ? 'unlike' : 'like'); }
finally { if (like.isConnected) like.disabled = false; }
});
const more = document.createElement('button');
more.type = 'button';
more.className = 'channel-action-item thread-comment__more';
more.innerHTML = iconHtml('more');
more.setAttribute('aria-label', `Действия с комментарием ${author}`);
const menuItems = [
{ label: 'Ответить', iconHtml: iconHtml('reply'), action: () => handlers.setReplyTo(node) },
{ label: 'Поделиться комментарием', iconHtml: iconHtml('share'), action: () => handlers.shareComment(node) },
{ label: 'Скопировать текст', iconHtml: iconHtml('copy'), action: async () => {
try { await navigator.clipboard.writeText(parsed.text); showToast('Текст скопирован'); }
catch { showToast('Не удалось скопировать', { kind: 'error' }); }
} },
{ label: 'Данные публикации', iconHtml: iconHtml('info'), action: () => openBlockchainDetailsModal(buildBlockchainDetails({ target, authorLogin: author, timestampMs: node?.createdAtMs, text: resolveNodeText(node), raw: node, localNumber: 0, msgSubType: Number(node?.msgSubType || 0) }), { isActive: handlers.isActive }) },
];
if (versions.length > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions }) });
if (isOwn) {
menuItems.splice(1, 0, { label: 'Редактировать', iconHtml: iconHtml('edit'), action: () => openEditMessageModal({
isActive: handlers.isActive,
draftKey: `edit:${key}`,
initialText: parsed.text,
allowEmptyText: parsed.attachments.length > 0,
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsed.attachments), { isChannelPost: false }),
onDelete: async () => handlers.onEdit(target, '', { isChannelPost: false, isDelete: true }),
}) });
menuItems.push({ divider: true }, { label: 'Удалить', danger: true, iconHtml: iconHtml('trash'), action: async () => {
if (!await confirmDialog({ title: 'Удалить комментарий?', text: 'Он скроется из обсуждения. Прежние версии останутся в блокчейне.', confirmLabel: 'Удалить', danger: true })) return;
try { await handlers.onEdit(target, '', { isChannelPost: false, isDelete: true }); }
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить комментарий.')); }
} });
}
const menu = createDropdownMenu({ anchorEl: more, items: menuItems });
item.cleanup = () => menu.destroy();
actions.append(reply, like, more);
item.append(actions);
return item;
}
function renderDescendants(items, handlers, nextNumber, depth = 0, parent = null) { function renderDescendants(items, handlers, nextNumber, depth = 0, parent = null) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'stack'; wrap.className = 'stack';
@@ -1199,124 +1025,6 @@ function renderSkeleton(screen) {
return wrap; return wrap;
} }
// «Не уведомлять об ответах» — пока только на этом устройстве (серверной настройки нет, см. docs/UI-Design)
const THREAD_MUTE_KEY = 'shine-ui-thread-muted-v1';
function readMuted() { try { return JSON.parse(localStorage.getItem(THREAD_MUTE_KEY) || '{}') || {}; } catch { return {}; } }
function isThreadMuted(key) { return Boolean(readMuted()[key]); }
function setThreadMuted(key, muted) {
const all = readMuted();
if (muted) all[key] = Date.now(); else delete all[key];
try { localStorage.setItem(THREAD_MUTE_KEY, JSON.stringify(all)); } catch { /* приватный режим */ }
}
// Черновик и адресат ответа принадлежат конкретному обсуждению
const threadDrafts = new Map();
function createThreadComposer({ draftKey, isAuthorized, onLogin, onSend }) {
const root = document.createElement('div');
root.className = 'channel-composer thread-composer';
if (!isAuthorized) {
const login = document.createElement('button');
login.type = 'button';
login.className = 'primary-btn channel-main-action';
login.textContent = 'Войти и ответить';
login.addEventListener('click', onLogin);
root.append(login);
return { root, setReplyTo() {} };
}
const draft = threadDrafts.get(draftKey) || { text: '', replyTo: null };
threadDrafts.set(draftKey, draft);
const replying = document.createElement('div');
replying.className = 'thread-replying';
const row = document.createElement('div');
row.className = 'compose-row';
const field = document.createElement('div');
field.className = 'compose-field';
const input = document.createElement('textarea');
input.rows = 1;
input.className = 'compose-field__input';
input.value = draft.text;
const emojiBtn = document.createElement('button');
emojiBtn.type = 'button';
emojiBtn.className = 'compose-field__emoji';
emojiBtn.setAttribute('aria-label', 'Эмодзи');
emojiBtn.innerHTML = iconHtml('emoji');
field.append(input, emojiBtn);
const send = document.createElement('button');
send.type = 'button';
send.className = 'compose-send';
send.setAttribute('aria-label', 'Отправить');
send.innerHTML = iconHtml('send');
row.append(field, send);
const emojiSlot = document.createElement('div');
emojiSlot.className = 'thread-composer__emoji';
emojiSlot.hidden = true;
root.append(replying, row, emojiSlot);
const sync = () => {
draft.text = input.value;
send.disabled = !input.value.trim();
input.placeholder = draft.replyTo ? `Ответить ${draft.replyTo.authorLogin || ''}…` : 'Написать комментарий…';
replying.replaceChildren();
replying.hidden = !draft.replyTo;
if (draft.replyTo) {
const quote = document.createElement('div');
quote.className = 'thread-quote';
const a = document.createElement('strong');
a.textContent = String(draft.replyTo.authorLogin || '');
const t = document.createElement('span');
t.textContent = parseMessageAttachments(resolveNodeText(draft.replyTo)).text;
quote.append(a, t);
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'thread-replying__cancel';
cancel.setAttribute('aria-label', 'Не отвечать на комментарий');
cancel.innerHTML = iconHtml('close');
cancel.addEventListener('click', () => { draft.replyTo = null; sync(); });
replying.append(quote, cancel);
}
};
input.addEventListener('input', sync);
emojiBtn.addEventListener('click', () => {
if (!emojiSlot.childElementCount) {
emojiSlot.append(createEmojiPicker({ onSelect: (emoji) => {
const at = input.selectionStart ?? input.value.length;
input.value = `${input.value.slice(0, at)}${emoji}${input.value.slice(at)}`;
sync();
} }));
}
emojiSlot.hidden = !emojiSlot.hidden;
});
const submit = async () => {
const text = input.value.trim();
if (!text) return;
send.disabled = true;
try {
await onSend(text, draft.replyTo);
draft.text = '';
draft.replyTo = null;
input.value = '';
emojiSlot.hidden = true;
} catch (error) {
showToast(toUserMessage(error, 'Комментарий не отправлен. Текст сохранён.'), { kind: 'error' });
} finally {
sync();
}
};
send.addEventListener('click', submit);
bindSubmitOnPlainEnter(input, submit);
sync();
return {
root,
setReplyTo(node) {
draft.replyTo = node;
sync();
input.focus();
},
};
}
export function render({ navigate, route, chrome }) { export function render({ navigate, route, chrome }) {
let selector = parseThreadSelector(route); let selector = parseThreadSelector(route);
const channelDisplayName = resolveChannelDisplayName(selector?.channel); const channelDisplayName = resolveChannelDisplayName(selector?.channel);
@@ -1334,31 +1042,19 @@ export function render({ navigate, route, chrome }) {
const threadHeaderButton = document.createElement('button'); const threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button'; threadHeaderButton.type = 'button';
threadHeaderButton.className = 'topbar-identity channel-header-route-btn'; threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
const threadHeaderText = document.createElement('span');
threadHeaderText.className = 'topbar-identity__text';
const threadHeaderTitle = document.createElement('span'); const threadHeaderTitle = document.createElement('span');
threadHeaderTitle.className = 'topbar-identity__title'; threadHeaderTitle.className = 'channel-header-title';
threadHeaderTitle.textContent = 'Обсуждение'; threadHeaderTitle.textContent = 'Обсуждение';
const threadHeaderChannel = document.createElement('span'); const threadHeaderChannel = document.createElement('span');
threadHeaderChannel.className = 'topbar-identity__sub'; threadHeaderChannel.className = 'channel-header-owner';
threadHeaderChannel.textContent = '…'; threadHeaderChannel.textContent = '…';
threadHeaderText.append(threadHeaderTitle, threadHeaderChannel); threadHeaderButton.append(threadHeaderTitle, threadHeaderChannel);
threadHeaderButton.append(threadHeaderText);
threadHeaderButton.disabled = true; threadHeaderButton.disabled = true;
// Меню обсуждения (V4): к записи, уведомления об ответах, поделиться записью
let threadMenuActions = null;
const header = createTopBar({ const header = createTopBar({
center: threadHeaderButton, center: threadHeaderButton,
className: 'topbar--identity',
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) }, back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
actions: [{
label: '⋯',
ariaLabel: 'Меню обсуждения',
className: 'thread-menu-btn',
menu: { minWidth: 240, items: () => threadMenuActions?.() || [] },
}],
}); });
header.classList.add('channel-thread-topbar'); header.classList.add('channel-thread-topbar');
chrome?.setTopbar(header); chrome?.setTopbar(header);
@@ -1700,7 +1396,7 @@ export function render({ navigate, route, chrome }) {
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно'; const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel; const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
if (threadHeaderButton) { if (threadHeaderButton) {
threadHeaderChannel.textContent = channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle; threadHeaderChannel.textContent = `в канале «${channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle}»`;
threadHeaderButton.title = 'Открыть канал'; threadHeaderButton.title = 'Открыть канал';
threadHeaderButton.disabled = false; threadHeaderButton.disabled = false;
threadHeaderButton.onclick = (event) => { threadHeaderButton.onclick = (event) => {
@@ -1713,114 +1409,74 @@ export function render({ navigate, route, chrome }) {
} }
clearContent(); clearContent();
const channelOwner = extractLoginFromBlockchainName(selector?.channel?.ownerBlockchainName || focus?.channelInfo?.ownerBlockchainName || ''); let localSeq = 0;
const openPost = () => { const nextNumber = () => {
const routeToChannel = buildChannelRouteFromThread(selector, resolvedChannelLabel); localSeq += 1;
if (routeToChannel) navigate(routeToChannel); return localSeq;
};
const focusTarget = focus ? buildTargetFromNode(focus) : null;
threadMenuActions = () => {
const muted = isThreadMuted(routeKey);
return [
{ label: 'Перейти к записи', iconHtml: iconHtml('doc'), action: openPost },
{ label: muted ? 'Уведомлять об ответах' : 'Не уведомлять об ответах', iconHtml: iconHtml(muted ? 'bell' : 'bell-off'), action: () => {
setThreadMuted(routeKey, !muted);
showToast(muted ? 'Уведомления об ответах включены' : 'Ответы в этом обсуждении не будут приходить в уведомления');
} },
{ label: 'Поделиться записью', iconHtml: iconHtml('share'), action: () => {
if (!focusTarget) return;
openShareSheet({
title: 'Поделиться записью',
kicker: `${resolvedChannelTitle} · запись`,
text: parseMessageAttachments(resolveNodeText(focus)).text,
url: buildAbsoluteRouteUrl(buildThreadRouteFromTarget(focusTarget, selector)),
});
} },
];
}; };
let ancestorsWrap = null;
if (ancestors.length) { if (ancestors.length) {
const ancestorsWrap = document.createElement('div'); ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'thread-block thread-block--ancestors'; ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
ancestors.forEach((node) => ancestorsWrap.append(renderThreadContext(node, { onOpenPost: openPost }))); ancestors.forEach((node, index) => {
screen.append(ancestorsWrap); const heading = index === 0 ? resolveChannelHeadingFromNode(node) : '';
} ancestorsWrap.append(renderNodeCard(node, heading, handlers, nextNumber()));
if (focus) {
const focusWrap = document.createElement('div');
focusWrap.className = 'thread-block thread-block--focus';
focusWrap.append(renderThreadContext(focus, { onOpenPost: openPost }));
screen.append(focusWrap);
}
const comments = flattenDescendants(descendants);
const listWrap = document.createElement('div');
listWrap.className = 'thread-block thread-block--replies';
const listTitle = document.createElement('h3');
listTitle.className = 'thread-section-title';
listTitle.textContent = `Комментарии · ${comments.length}`;
listWrap.append(listTitle);
// Возврат к ответу после перехода по цитате
const returnBtn = document.createElement('button');
returnBtn.type = 'button';
returnBtn.className = 'thread-return';
returnBtn.hidden = true;
returnBtn.textContent = 'Вернуться к ответу';
let returnKey = '';
const highlight = (el) => {
screen.querySelectorAll('.thread-comment.is-highlight').forEach((x) => x.classList.remove('is-highlight'));
el?.classList.add('is-highlight');
};
returnBtn.addEventListener('click', () => {
const el = listWrap.querySelector(`[data-message-key="${CSS.escape(returnKey)}"]`);
el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
highlight(el);
returnBtn.hidden = true;
});
const commentHandlers = {
...handlers,
jumpTo: (key, fromKey) => {
const el = listWrap.querySelector(`[data-message-key="${CSS.escape(key)}"]`);
if (!el) return;
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
highlight(el);
returnKey = fromKey;
returnBtn.hidden = false;
},
setReplyTo: (node) => composerApi?.setReplyTo(node),
shareComment: (node) => openShareSheet({
title: 'Поделиться комментарием',
kicker: `${node.authorLogin} · комментарий`,
text: parseMessageAttachments(resolveNodeText(node)).text,
url: `${buildAbsoluteRouteUrl(buildThreadRouteFromTarget(focusTarget, selector))}#comment-${node?.messageRef?.blockNumber ?? ''}`,
}),
};
if (comments.length) {
comments.forEach(({ node, parent }) => {
try { listWrap.append(renderComment({ node, parent, focus, channelOwner, handlers: commentHandlers })); }
catch (error) { logThreadRuntimeError('render_comment', error); }
}); });
}
let focusWrap = null;
if (focus) {
focusWrap = document.createElement('div');
focusWrap.className = 'stack thread-block thread-block--focus';
const focusTitle = document.createElement('h3');
focusTitle.className = 'section-title';
focusTitle.textContent = 'Исходное сообщение';
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
const composer = document.createElement('div');
composer.className = 'channel-composer';
const reply = document.createElement('button');
reply.type = 'button';
reply.className = 'channel-compose-bar';
reply.innerHTML = `<span class="channel-compose-bar__text">${state.session.isAuthorized ? 'Написать ответ…' : 'Войти и ответить'}</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
reply.addEventListener('click', () => {
const parsed = parseMessageAttachments(resolveNodeText(focus));
openReplyModal({
draftKey: `message:${messageRefKey(buildTargetFromNode(focus))}`,
context: { author: focus.authorLogin, text: parsed.text, attachmentLabel: parsed.attachments[0]?.name },
isActive: () => !disposed,
onSubmit: (text) => handlers.onReply(buildTargetFromNode(focus), text),
});
});
composer.append(reply);
chrome?.setComposer(composer);
}
const descendantsWrap = document.createElement('div');
descendantsWrap.className = 'stack thread-block thread-block--replies';
const descendantsTitle = document.createElement('h3');
descendantsTitle.className = 'section-title';
descendantsTitle.textContent = `Ответы · ${Math.max(0, Number(focus?.repliesCount || descendants.length))}`;
descendantsWrap.append(descendantsTitle);
if (descendants.length) {
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
} else { } else {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'empty-note thread-empty'; empty.className = 'empty-note';
empty.textContent = 'Пока нет комментариев. Можно начать разговор.'; empty.textContent = 'Пока нет ответов. Начните обсуждение.';
listWrap.append(empty); descendantsWrap.append(empty);
} }
screen.append(listWrap, returnBtn);
let composerApi = null; if (ancestorsWrap) {
if (focus) { screen.append(ancestorsWrap);
composerApi = createThreadComposer({ const divider = document.createElement('div');
draftKey: routeKey, divider.className = 'thread-history-divider';
isAuthorized: state.session.isAuthorized, screen.append(divider);
onLogin: () => { state.authReturnHash = window.location.pathname || '/channels'; navigate('login-view'); },
onSend: async (text, replyTo) => handlers.onReply(replyTo ? buildTargetFromNode(replyTo) : focusTarget, text),
});
chrome?.setComposer(composerApi.root);
} }
const focusWrap = screen.querySelector('.thread-block--focus');
if (focusWrap) screen.append(focusWrap);
screen.append(descendantsWrap);
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq)); trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
const hasPendingScroll = pendingThreadScroll.has(routeKey); const hasPendingScroll = pendingThreadScroll.has(routeKey);
+35 -61
View File
@@ -16,8 +16,8 @@ import {
animatePress, animatePress,
createSkeletonCard, createSkeletonCard,
dayKey, dayKey,
formatClockTime,
formatDayLabel, formatDayLabel,
formatPostStamp,
formatRelativeTime, formatRelativeTime,
longPressFeel, longPressFeel,
shareOrCopyLink, shareOrCopyLink,
@@ -644,7 +644,7 @@ function openChannelMetaDetailsModal({
: 'Не установлен'; : 'Не установлен';
const changedAtLabel = changedAtMs const changedAtLabel = changedAtMs
? new Date(Number(changedAtMs)).toLocaleString('ru-RU') ? new Date(Number(changedAtMs)).toLocaleString('ru-RU')
: '–'; : '—';
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
root.innerHTML = ` root.innerHTML = `
<div class="modal" id="about-channel-modal"> <div class="modal" id="about-channel-modal">
@@ -658,7 +658,7 @@ function openChannelMetaDetailsModal({
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong> <span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
<span>Владелец</span><strong>${escapeHtml(channel.ownerName || 'автор')}</strong> <span>Владелец</span><strong>${escapeHtml(channel.ownerName || 'автор')}</strong>
<span>Системное имя</span><code>${escapeHtml(channel.name || 'channel')}</code> <span>Системное имя</span><code>${escapeHtml(channel.name || 'channel')}</code>
<span>Название</span><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name || '–')}</strong> <span>Название</span><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name || '—')}</strong>
<span>Описание</span><span>${escapeHtml(channel.description || 'Описание не задано.')}</span> <span>Описание</span><span>${escapeHtml(channel.description || 'Описание не задано.')}</span>
<span>Аватар</span><span>${escapeHtml(avatarState)}</span> <span>Аватар</span><span>${escapeHtml(avatarState)}</span>
</div> </div>
@@ -840,7 +840,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code> <span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code> <span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code> <span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '–')}</code> <span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}</code>
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code> <span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
<span>Подпись</span><code>${escapeHtml(details.signature)}</code> <span>Подпись</span><code>${escapeHtml(details.signature)}</code>
</div> </div>
@@ -1079,7 +1079,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
item.className = 'ui-button entrypoint-history-item'; item.className = 'ui-button entrypoint-history-item';
item.innerHTML = ` item.innerHTML = `
<strong>${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')}</strong> <strong>${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')}</strong>
<span>#${escapeHtml(post.localNumber || '–')}</span> <span>#${escapeHtml(post.localNumber || '—')}</span>
<p>${escapeHtml(String(parsed.text || '').trim() || 'Без текста')}</p> <p>${escapeHtml(String(parsed.text || '').trim() || 'Без текста')}</p>
`; `;
item.addEventListener('click', () => { item.addEventListener('click', () => {
@@ -1171,7 +1171,7 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
if (textEl) textEl.focus(); if (textEl) textEl.focus();
} }
function openAddMessageModal({ channelName, channelTitle = '', onSubmit, navigate, isActive = () => true }) { function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
const typeWrap = document.createElement('label'); const typeWrap = document.createElement('label');
typeWrap.className = 'channel-editor__type'; typeWrap.className = 'channel-editor__type';
typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>'; typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>';
@@ -1188,8 +1188,7 @@ function openAddMessageModal({ channelName, channelTitle = '', onSubmit, navigat
id: 'channel-message-modal', id: 'channel-message-modal',
title: 'Новая запись', title: 'Новая запись',
submitLabel: 'Опубликовать', submitLabel: 'Опубликовать',
placeholder: 'Чем хотите поделиться?', placeholder: 'Напишите сообщение',
destination: channelTitle ? `В канал «${channelTitle}»` : '',
key: `channel-post:${channelName}`, key: `channel-post:${channelName}`,
extraControl: typeWrap, extraControl: typeWrap,
isActive, isActive,
@@ -1224,7 +1223,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
const text = String(item?.text || '').trim() || 'удалено'; const text = String(item?.text || '').trim() || 'удалено';
row.innerHTML = ` row.innerHTML = `
<strong>Версия ${index + 1}</strong> <strong>Версия ${index + 1}</strong>
<div class="meta-muted">${ts > 0 ? formatRelativeTime(ts) : '–'}</div> <div class="meta-muted">${ts > 0 ? formatRelativeTime(ts) : '—'}</div>
<p class="channel-message-body">${text}</p> <p class="channel-message-body">${text}</p>
`; `;
list.append(row); list.append(row);
@@ -1276,8 +1275,6 @@ function mapApiMessageToPost(message, selector, localNumber) {
primaryLikesCount: Number(message?.primaryLikesCount || 0), primaryLikesCount: Number(message?.primaryLikesCount || 0),
shiningLikesCount: Number(message?.shiningLikesCount || 0), shiningLikesCount: Number(message?.shiningLikesCount || 0),
repliesCount: Number(message?.repliesCount || 0), repliesCount: Number(message?.repliesCount || 0),
// Кто недавно ответил — для миниатюр у счётчика обсуждения. Сервер пока не отдаёт (см. docs/UI-Design).
recentRepliers: Array.isArray(message?.recentRepliers) ? message.recentRepliers.slice(0, 2) : [],
ratingsCount: Number(message?.ratingsCount || 0), ratingsCount: Number(message?.ratingsCount || 0),
timestampMs: resolveMessageTimestampMs(message), timestampMs: resolveMessageTimestampMs(message),
messageRef, messageRef,
@@ -1510,7 +1507,6 @@ async function loadFromApi(route, channelId) {
avaSha256: String(payload.channel?.avaSha256 || '').trim(), avaSha256: String(payload.channel?.avaSha256 || '').trim(),
avaSize: Number(payload.channel?.avaSize || 0), avaSize: Number(payload.channel?.avaSize || 0),
metaUpdatedAtMs: Number(payload.channel?.metaUpdatedAtMs || 0), metaUpdatedAtMs: Number(payload.channel?.metaUpdatedAtMs || 0),
subscribersCount: Number(payload.channel?.subscribersCount || 0),
ownerName: ownerLogin || 'неизвестно', ownerName: ownerLogin || 'неизвестно',
}, },
posts, posts,
@@ -1866,7 +1862,7 @@ function renderPostCard(post, {
const timestamp = document.createElement('div'); const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time'; timestamp.className = 'channel-message-time';
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '–'; timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
if (post.timestampMs) timestamp.title = new Date(post.timestampMs).toLocaleString('ru-RU'); if (post.timestampMs) timestamp.title = new Date(post.timestampMs).toLocaleString('ru-RU');
if (versionsTotal > 1) { if (versionsTotal > 1) {
const editedMarker = document.createElement('span'); const editedMarker = document.createElement('span');
@@ -2038,12 +2034,6 @@ function renderPostCard(post, {
<span class="channel-action-label">Обсуждение</span> <span class="channel-action-label">Обсуждение</span>
<span class="channel-action-counter">${post.repliesCount || ''}</span> <span class="channel-action-counter">${post.repliesCount || ''}</span>
`; `;
if (post.recentRepliers.length) {
const faces = document.createElement('span');
faces.className = 'channel-action-faces';
post.recentRepliers.forEach((login) => faces.append(renderUserAvatar({ login, size: 'xs', className: 'channel-action-face' })));
discussionButton.append(faces);
}
setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`); setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`);
discussionButton.addEventListener('click', (event) => { discussionButton.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
@@ -2077,6 +2067,8 @@ function renderPostCard(post, {
// Rating/opinion action is intentionally hidden from UI for now. // Rating/opinion action is intentionally hidden from UI for now.
// Backend/subtype/counters remain supported so the feature can be restored later. // Backend/subtype/counters remain supported so the feature can be restored later.
actions.append(likeButton, discussionButton);
const shareButton = document.createElement('button'); const shareButton = document.createElement('button');
shareButton.type = 'button'; shareButton.type = 'button';
shareButton.className = 'ui-button channel-action-item channel-action-share'; shareButton.className = 'ui-button channel-action-item channel-action-share';
@@ -2092,9 +2084,8 @@ function renderPostCard(post, {
await onShare(route); await onShare(route);
}); });
// Порядок как в макете V4: лайк → поделиться → обсуждение; дата и «⋯» — справа actions.append(shareButton);
actions.append(likeButton, shareButton, discussionButton); menuItems.unshift({ label: 'Ответить', action: () => replyButton.click() });
menuItems.unshift({ label: 'Ответить', iconHtml: iconHtml('reply'), action: () => replyButton.click() });
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) { if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
const originalBtn = document.createElement('button'); const originalBtn = document.createElement('button');
originalBtn.type = 'button'; originalBtn.type = 'button';
@@ -2136,7 +2127,7 @@ function renderPostCard(post, {
msgSubType: post.msgSubType, msgSubType: post.msgSubType,
}), { isActive }); }), { isActive });
}); });
menuItems.push({ label: 'Данные публикации', iconHtml: iconHtml('info'), action: () => detailsButton.click() }); menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() });
if (post.isOwnMessage) { if (post.isOwnMessage) {
const editButton = document.createElement('button'); const editButton = document.createElement('button');
editButton.type = 'button'; editButton.type = 'button';
@@ -2158,9 +2149,8 @@ function renderPostCard(post, {
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }), onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
}); });
}); });
menuItems.unshift({ label: 'Редактировать', iconHtml: iconHtml('edit'), action: () => editButton.click() }); menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
menuItems.push({ divider: true }); menuItems.push({ label: 'Удалить', danger: true, action: async () => {
menuItems.push({ label: 'Удалить', danger: true, iconHtml: iconHtml('trash'), action: async () => {
if (!await confirmDialog({ if (!await confirmDialog({
title: 'Удалить сообщение?', title: 'Удалить сообщение?',
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.', text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
@@ -2174,7 +2164,7 @@ function renderPostCard(post, {
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) }); if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
attachMessageMenu(card, headRow, menuItems); attachMessageMenu(card, headRow, menuItems);
if (isOwnerPost) { if (isOwnerPost) {
if (post.timestampMs) timestamp.textContent = formatPostStamp(post.timestampMs); if (post.timestampMs) timestamp.textContent = formatClockTime(post.timestampMs);
const meta = document.createElement('div'); const meta = document.createElement('div');
meta.className = 'channel-message-meta'; meta.className = 'channel-message-meta';
const moreButton = headRow.querySelector('.channel-message-more'); const moreButton = headRow.querySelector('.channel-message-more');
@@ -2203,12 +2193,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const actionButton = document.createElement('button'); const actionButton = document.createElement('button');
actionButton.type = 'button'; actionButton.type = 'button';
actionButton.className = 'primary-btn channel-main-action'; actionButton.className = 'primary-btn channel-main-action';
actionButton.innerHTML = `${iconHtml('bell')}<span>${state.session.isAuthorized ? 'Подписаться' : 'Войти и подписаться'}</span>`; actionButton.textContent = state.session.isAuthorized ? 'Подписаться на канал' : 'Войти и подписаться';
const addMessageButton = document.createElement('button'); const addMessageButton = document.createElement('button');
addMessageButton.type = 'button'; addMessageButton.type = 'button';
addMessageButton.className = 'channel-new-post'; addMessageButton.className = 'channel-compose-bar';
addMessageButton.innerHTML = `${iconHtml('compose')}<span>Новая запись</span>`; addMessageButton.setAttribute('aria-label', 'Написать в канал');
addMessageButton.innerHTML = `<span class="channel-compose-bar__text">Написать в канал…</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
addMessageButton.addEventListener('click', (event) => { addMessageButton.addEventListener('click', (event) => {
animatePress(event.currentTarget); animatePress(event.currentTarget);
handlers.onAddMessage(); handlers.onAddMessage();
@@ -2245,8 +2236,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const itemDayKey = dayKey(item.timestampMs); const itemDayKey = dayKey(item.timestampMs);
const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey; const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey;
if (startsNewDay) lastDayKey = itemDayKey; if (startsNewDay) lastDayKey = itemDayKey;
// В ленте канала дата стоит у каждой записи; плашки дней остаются только в дневнике if (startsNewDay && item.type !== 'meta') {
if (startsNewDay && item.type !== 'meta' && channelData.isDiary) {
const separator = document.createElement('div'); const separator = document.createElement('div');
separator.className = 'channel-day-separator'; separator.className = 'channel-day-separator';
separator.innerHTML = `<span>${escapeHtml(formatDayLabel(item.timestampMs))}</span>`; separator.innerHTML = `<span>${escapeHtml(formatDayLabel(item.timestampMs))}</span>`;
@@ -2310,11 +2300,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
composer.append(addMessageButton); composer.append(addMessageButton);
handlers.chrome?.setComposer(composer); handlers.chrome?.setComposer(composer);
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) { } else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
screen.append(feed); screen.append(actionButton, feed);
const composer = document.createElement('div');
composer.className = 'channel-composer';
composer.append(actionButton);
handlers.chrome?.setComposer(composer);
} else { } else {
screen.append(feed); screen.append(feed);
} }
@@ -2426,18 +2412,17 @@ export function render({ navigate, route, chrome }) {
let activeChannelData = null; let activeChannelData = null;
let activeOpenEntrypointHistory = null; let activeOpenEntrypointHistory = null;
let activeAddMessage = null;
const channelHeaderButton = document.createElement('button'); const channelHeaderButton = document.createElement('button');
channelHeaderButton.type = 'button'; channelHeaderButton.type = 'button';
channelHeaderButton.className = 'topbar-identity channel-header-route-btn'; channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
channelHeaderButton.innerHTML = '<span class="topbar-identity__text"><span class="topbar-identity__title">Канал</span><span class="topbar-identity__sub">Загрузка…</span></span>'; channelHeaderButton.innerHTML = '<span class="channel-header-title">Канал</span><span class="channel-header-owner">Загрузка…</span>';
channelHeaderButton.disabled = true; channelHeaderButton.disabled = true;
const header = createTopBar({ const header = createTopBar({
center: channelHeaderButton, center: channelHeaderButton,
back: { onClick: () => navigate('channels-list') }, back: { onClick: () => navigate('channels-list') },
className: 'channel-view-topbar topbar--identity', className: 'channel-view-topbar',
actions: [ actions: [
{ {
label: '⋮', label: '⋮',
@@ -2457,20 +2442,16 @@ export function render({ navigate, route, chrome }) {
const aboutRoute = makeShineChannelAboutRoute(routeArgs); const aboutRoute = makeShineChannelAboutRoute(routeArgs);
const donateRoute = makeShineChannelDonateRoute(routeArgs); const donateRoute = makeShineChannelDonateRoute(routeArgs);
const items = []; const items = [];
items.push({ label: 'О канале', iconHtml: iconHtml('info'), action: () => { if (aboutRoute) navigate(aboutRoute); } }); items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
if (apiData?.isOwnChannel && !apiData?.isDiary) {
items.push({ label: 'Новая запись', iconHtml: iconHtml('compose'), action: () => activeAddMessage?.() });
}
items.push({ items.push({
label: 'Оглавление', label: 'Оглавление',
iconHtml: iconHtml('list'),
action: () => { action: () => {
if (activeOpenEntrypointHistory) activeOpenEntrypointHistory(); if (activeOpenEntrypointHistory) activeOpenEntrypointHistory();
else showToast('В этом канале пока нет оглавления'); else showToast('В этом канале пока нет оглавления');
}, },
}); });
if (!apiData?.isOwnChannel) { if (!apiData?.isOwnChannel) {
items.push({ label: 'Поддержать автора', iconHtml: iconHtml('gift'), action: () => { if (donateRoute) navigate(donateRoute); } }); items.push({ label: 'Поддержать автора', action: () => { if (donateRoute) navigate(donateRoute); } });
} }
if (!apiData?.isOwnChannel && !isStoriesChannel(apiData?.channel)) { if (!apiData?.isOwnChannel && !isStoriesChannel(apiData?.channel)) {
if (apiData?.isSubscribed) { if (apiData?.isSubscribed) {
@@ -2887,21 +2868,15 @@ export function render({ navigate, route, chrome }) {
}; };
activeOpenEntrypointHistory = openEntrypointHistory; activeOpenEntrypointHistory = openEntrypointHistory;
if (channelHeaderButton) { if (channelHeaderButton) {
// Короткий адрес канала и число подписчиков; владелец и полный путь — в «О канале» const ownerLabel = String(apiData?.channel?.ownerName || '').trim();
const slug = String(apiData?.channel?.name || '').trim();
const subs = Number(apiData?.channel?.subscribersCount || 0);
channelHeaderButton.replaceChildren(); channelHeaderButton.replaceChildren();
const text = document.createElement('span');
text.className = 'topbar-identity__text';
const titleNode = document.createElement('span'); const titleNode = document.createElement('span');
titleNode.className = 'topbar-identity__title'; titleNode.className = 'channel-header-title';
titleNode.textContent = titleLabel; titleNode.textContent = titleLabel;
const subNode = document.createElement('span'); const ownerNode = document.createElement('span');
subNode.className = 'topbar-identity__sub'; ownerNode.className = 'channel-header-owner';
subNode.textContent = apiData?.isDiary ? 'Только вам' : [slug ? `@${slug}` : '', subs ? String(subs) : ''].filter(Boolean).join(' · '); ownerNode.textContent = ownerLabel ? `@${ownerLabel}` : 'О канале';
text.append(titleNode, subNode); channelHeaderButton.append(titleNode, ownerNode);
channelHeaderButton.append(createChannelAvatarElement(apiData?.channel, 40), text);
channelHeaderButton.setAttribute('aria-label', `${titleLabel}. О канале`);
channelHeaderButton.disabled = false; channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => { channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget); animatePress(event.currentTarget);
@@ -2935,10 +2910,9 @@ export function render({ navigate, route, chrome }) {
restorePosition, restorePosition,
showStatus, showStatus,
isActive: () => !disposed, isActive: () => !disposed,
onAddMessage: activeAddMessage = () => { onAddMessage: () => {
openAddMessageModal({ openAddMessageModal({
channelName: apiData?.channel?.name || '', channelName: apiData?.channel?.name || '',
channelTitle: apiData?.channel?.displayTitle || '',
navigate, navigate,
isActive: () => !disposed, isActive: () => !disposed,
onSubmit: async ({ text: bodyText, msgSubType }) => { onSubmit: async ({ text: bodyText, msgSubType }) => {
+2 -2
View File
@@ -934,7 +934,7 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p> <p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
</div> </div>
<div class="channel-row-controls"> <div class="channel-row-controls">
<span class="channel-row-time">–</span> <span class="channel-row-time">—</span>
</div> </div>
`; `;
row.prepend(createChannelAvatar(channel)); row.prepend(createChannelAvatar(channel));
@@ -1163,7 +1163,7 @@ export function render({ navigate, route, chrome }) {
className: 'topbar--root', className: 'topbar--root',
actions: [ actions: [
{ {
icon: 'plus', label: '+',
title: 'Создать канал', title: 'Создать канал',
ariaLabel: 'Создать канал', ariaLabel: 'Создать канал',
className: 'channels-create-btn', className: 'channels-create-btn',
+6 -22
View File
@@ -1,4 +1,3 @@
import { iconHtml } from '../components/ui-icon.js';
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { createDropdownMenu } from '../components/dropdown-menu.js'; import { createDropdownMenu } from '../components/dropdown-menu.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
@@ -99,18 +98,18 @@ function createChatHeaderParts(login, navigate) {
const identityButton = document.createElement('button'); const identityButton = document.createElement('button');
identityButton.type = 'button'; identityButton.type = 'button';
identityButton.className = 'topbar-identity chat-header-peer-btn'; identityButton.className = 'chat-header-peer-btn';
identityButton.title = `Меню ${cleanLogin}`; identityButton.title = `Меню ${cleanLogin}`;
identityButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`); identityButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
const avatarSlot = document.createElement('span'); const avatarSlot = document.createElement('span');
avatarSlot.className = 'chat-header-avatar-slot'; avatarSlot.className = 'chat-header-avatar-slot';
const textWrap = document.createElement('span'); const textWrap = document.createElement('span');
textWrap.className = 'topbar-identity__text chat-header-peer-text'; textWrap.className = 'chat-header-peer-text';
const nameEl = document.createElement('span'); const nameEl = document.createElement('span');
nameEl.className = 'topbar-identity__title chat-header-peer-name'; nameEl.className = 'chat-header-peer-name';
const metaEl = document.createElement('span'); const metaEl = document.createElement('span');
metaEl.className = 'topbar-identity__sub chat-header-peer-meta'; metaEl.className = 'chat-header-peer-meta';
textWrap.append(nameEl, metaEl); textWrap.append(nameEl, metaEl);
identityButton.append(avatarSlot, textWrap); identityButton.append(avatarSlot, textWrap);
@@ -120,9 +119,7 @@ function createChatHeaderParts(login, navigate) {
const fullName = [firstName, lastName].filter(Boolean).join(' '); const fullName = [firstName, lastName].filter(Boolean).join(' ');
nameEl.textContent = fullName || cleanLogin; nameEl.textContent = fullName || cleanLogin;
const relation = chatRelationLabel(currentPeer?.relationType); const relation = chatRelationLabel(currentPeer?.relationType);
// Статус аккаунта, а не «в сети»: присутствие сервер не сообщает, выдумывать его нельзя metaEl.textContent = [fullName ? `@${cleanLogin}` : '', relation].filter(Boolean).join(' · ');
const shining = String(currentPeer?.shineStatus || '').toLowerCase() === 'shining' ? '✧ сияющий аккаунт' : '';
metaEl.textContent = [fullName ? `@${cleanLogin}` : '', shining, relation].filter(Boolean).join(' · ');
metaEl.hidden = !metaEl.textContent; metaEl.hidden = !metaEl.textContent;
const avatar = renderUserAvatar({ const avatar = renderUserAvatar({
login: cleanLogin, login: cleanLogin,
@@ -771,14 +768,6 @@ function resolveDeliveryStatus(messages, msg) {
return '…'; return '…';
} }
// Статус сообщения — иконкой и словом для экранного чтения (цвет не единственный носитель смысла)
const DELIVERY_STATUS_VIEW = {
'✓✓': { icon: 'check-double', label: 'Прочитано' },
'✓': { icon: 'check', label: 'Отправлено' },
'!': { icon: 'alert', label: 'Не отправлено' },
'…': { icon: 'clock', label: 'Отправляется' },
};
function resolveDeliveryTone(messages, msg) { function resolveDeliveryTone(messages, msg) {
if (resolveEffectiveReadState(messages, msg).isRead) return 'read'; if (resolveEffectiveReadState(messages, msg).isRead) return 'read';
const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase(); const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase();
@@ -1210,11 +1199,7 @@ function renderLog(
if (status) { if (status) {
const statusNode = document.createElement('span'); const statusNode = document.createElement('span');
statusNode.className = `bubble-status bubble-status--${resolveDeliveryTone(messages, msg)}`; statusNode.className = `bubble-status bubble-status--${resolveDeliveryTone(messages, msg)}`;
const statusView = DELIVERY_STATUS_VIEW[status] || DELIVERY_STATUS_VIEW['…']; statusNode.textContent = status;
statusNode.innerHTML = iconHtml(statusView.icon);
statusNode.setAttribute('role', 'img');
statusNode.setAttribute('aria-label', statusView.label);
statusNode.title = statusView.label;
metaNode.append(statusNode); metaNode.append(statusNode);
} }
@@ -1444,7 +1429,6 @@ export function render({ navigate, route, chrome }) {
const chatHeaderParts = createChatHeaderParts(chatId, navigate); const chatHeaderParts = createChatHeaderParts(chatId, navigate);
const chatHeader = createTopBar({ const chatHeader = createTopBar({
center: chatHeaderParts.center, center: chatHeaderParts.center,
className: 'topbar--identity',
back: { label: '←', onClick: () => navigate('messages-list') }, back: { label: '←', onClick: () => navigate('messages-list') },
actions: [ actions: [
{ {
+3 -3
View File
@@ -63,9 +63,9 @@ export function render({navigate, chrome}) {
<div class="stack" style="gap:6px;"> <div class="stack" style="gap:6px;">
<p class="meta-muted">пользователь выбирает ключи для передачи</p> <p class="meta-muted">пользователь выбирает ключи для передачи</p>
<p class="meta-muted">передать можно только существующие ключи</p> <p class="meta-muted">передать можно только существующие ключи</p>
<p class="meta-muted">если ключа нет – он недоступен</p> <p class="meta-muted">если ключа нет — он недоступен</p>
<p class="meta-muted">blockchain key – можно передать или нет</p> <p class="meta-muted">blockchain key — можно передать или нет</p>
<p class="meta-muted">root key – только если существует</p> <p class="meta-muted">root key — только если существует</p>
<p class="meta-muted">client key передаётся всегда</p> <p class="meta-muted">client key передаётся всегда</p>
<p class="meta-muted">подключение происходит напрямую через QR</p> <p class="meta-muted">подключение происходит напрямую через QR</p>
<p class="meta-muted">сервер не используется</p> <p class="meta-muted">сервер не используется</p>
+3 -3
View File
@@ -209,9 +209,9 @@ function showClientUpdateHelp() {
'Если UI не обновился:\n\n' 'Если UI не обновился:\n\n'
+ '1) Закройте вкладки с Сиянием.\n' + '1) Закройте вкладки с Сиянием.\n'
+ `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n` + `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n`
+ '3) Если приложение установлено как PWA – удалите его с устройства.\n' + '3) Если приложение установлено как PWA — удалите его с устройства.\n'
+ `4) Откройте ${defaultServerHttp} заново и выполните вход.\n` + `4) Откройте ${defaultServerHttp} заново и выполните вход.\n`
+ '5) Если всё ещё старая версия – откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.' + '5) Если всё ещё старая версия — откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
); );
} }
@@ -315,7 +315,7 @@ export function render({navigate, chrome}) {
}; };
const syncPwaButtonLabel = () => { const syncPwaButtonLabel = () => {
if (isStandalonePwaMode()) { if (isStandalonePwaMode()) {
setPwaLabel('Приложение установлено – проверить уведомления'); setPwaLabel('Приложение установлено — проверить уведомления');
return; return;
} }
if (canInstallPwa()) { if (canInstallPwa()) {
+14 -4
View File
@@ -1,5 +1,4 @@
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { iconHtml } from '../components/ui-icon.js';
import { import {
authService, authService,
authorizeSession, authorizeSession,
@@ -59,7 +58,7 @@ function requestCardHtml(request) {
const shortCode = formatPairingShortCode(request?.shortCode || ''); const shortCode = formatPairingShortCode(request?.shortCode || '');
const client = String(request?.requesterClientPlatform || 'unknown'); const client = String(request?.requesterClientPlatform || 'unknown');
const requesterSessionType = Number(request?.requesterSessionType || 0); const requesterSessionType = Number(request?.requesterSessionType || 0);
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '–'; const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—';
const sessionOnly = requesterSessionType === SESSION_TYPE_WALLET; const sessionOnly = requesterSessionType === SESSION_TYPE_WALLET;
const sessionKind = pairingSessionKindLabel(requesterSessionType); const sessionKind = pairingSessionKindLabel(requesterSessionType);
return ` return `
@@ -126,8 +125,19 @@ async function runPairingOpWithSessionRestore(runAction) {
function makePasswordToggleIcons() { function makePasswordToggleIcons() {
return { return {
eye: iconHtml('eye', false, 'key-toggle-icon'), eye: `
eyeOff: iconHtml('eyeOff', false, 'key-toggle-icon'), <svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>
`,
eyeOff: `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
`,
}; };
} }
+45 -64
View File
@@ -10,14 +10,14 @@ import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '.
export const pageMeta = { id: 'entry-settings-view', title: 'Настройки входа', showAppChrome: false }; export const pageMeta = { id: 'entry-settings-view', title: 'Настройки входа', showAppChrome: false };
const SERVER_FIELDS = [ const SERVER_FIELDS = [
{ key: 'solanaServer', label: 'Сервер Solana' }, { key: 'solanaServer', label: 'Адрес Solana сервера' },
{ key: 'shineServerLogin', label: 'Сервер Сияния' }, { key: 'shineServerLogin', label: 'Сервер Сияния' },
{ key: 'arweaveServer', label: 'Сервер Arweave' }, { key: 'arweaveServer', label: 'Адрес сервера Arweave' },
]; ];
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack entry-settings'; screen.className = 'stack';
const draft = { const draft = {
language: state.entrySettings.language, language: state.entrySettings.language,
@@ -30,79 +30,62 @@ export function render({ navigate }) {
const timers = new Map(); const timers = new Map();
let disposed = false;
const checks = [];
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'entry-servers'; body.className = 'card stack';
SERVER_FIELDS.forEach((field) => { SERVER_FIELDS.forEach((field) => {
const block = document.createElement('div'); const block = document.createElement('div');
block.className = 'entry-server'; block.className = 'stack';
const head = document.createElement('div');
head.className = 'entry-server__head';
const title = document.createElement('label'); const title = document.createElement('label');
title.className = 'field-label'; title.className = 'field-label';
title.htmlFor = `entry-${field.key}`;
title.textContent = field.label; title.textContent = field.label;
// Статус — он же кнопка повторной проверки.
const checkButton = document.createElement('button');
checkButton.className = 'text-btn entry-status';
checkButton.type = 'button';
const input = document.createElement('input'); const input = document.createElement('input');
input.className = 'input'; input.className = 'input';
input.id = `entry-${field.key}`;
input.type = 'text'; input.type = 'text';
input.autocapitalize = 'off';
input.spellcheck = false;
input.value = draft[field.key]; input.value = draft[field.key];
const hint = document.createElement('p'); const controls = document.createElement('div');
hint.className = 'entry-server__hint'; controls.className = 'row wrap-row';
const checkButton = document.createElement('button');
checkButton.className = 'secondary-btn server-check-btn';
checkButton.type = 'button';
checkButton.textContent = 'Проверить';
const status = document.createElement('span');
status.className = 'status-line';
const applyStatus = (value, exactAddress = '') => { const applyStatus = (value, exactAddress = '') => {
if (disposed) return; draft.statuses[field.key] = value;
if (value !== 'checking') draft.statuses[field.key] = value; checkButton.classList.remove('is-available', 'is-unavailable');
checkButton.classList.remove('is-available', 'is-unavailable', 'is-checking'); status.classList.remove('is-available', 'is-unavailable');
checkButton.disabled = false;
if (value === 'available') { if (value === 'available') {
checkButton.textContent = 'Доступен'; status.textContent = field.key === 'shineServerLogin' && exactAddress
? `Доступен: ${exactAddress}`
: 'Доступен';
checkButton.classList.add('is-available'); checkButton.classList.add('is-available');
status.classList.add('is-available');
} else if (value === 'unavailable') { } else if (value === 'unavailable') {
checkButton.textContent = 'Недоступен'; status.textContent = 'Недоступен';
checkButton.classList.add('is-unavailable'); checkButton.classList.add('is-unavailable');
} else if (value === 'checking') { status.classList.add('is-unavailable');
checkButton.textContent = 'Проверка…';
checkButton.classList.add('is-checking');
checkButton.disabled = true;
} else { } else {
checkButton.textContent = 'Проверить'; status.textContent = field.key === 'shineServerLogin' && draft.shineServerHttp
} ? `Текущий адрес: ${draft.shineServerHttp}`
checkButton.setAttribute('aria-label', `${field.label}: ${checkButton.textContent}. Проверить ещё раз`); : 'Статус не проверен';
if (field.key === 'shineServerLogin') {
const address = exactAddress || draft.shineServerHttp;
hint.textContent = address || '';
hint.hidden = !address;
} }
}; };
let checkSeq = 0;
const runCheck = async () => { const runCheck = async () => {
draft[field.key] = input.value.trim(); draft[field.key] = input.value.trim();
window.clearTimeout(timers.get(field.key)); checkButton.disabled = true;
// Результат устаревшей проверки (адрес успели поменять) не должен перезаписать новый. checkButton.textContent = 'Проверка...';
const seq = ++checkSeq;
applyStatus('checking');
try { try {
if (field.key === 'shineServerLogin') { if (field.key === 'shineServerLogin') {
const resolved = await resolveAndCheckShineServerLogin(input.value, draft.solanaServer); const resolved = await resolveAndCheckShineServerLogin(input.value, draft.solanaServer);
if (seq !== checkSeq) return;
draft.shineServerHttp = resolved.httpBase; draft.shineServerHttp = resolved.httpBase;
draft.shineServer = resolved.wsUrl; draft.shineServer = resolved.wsUrl;
applyStatus(resolved.status, resolved.httpBase); applyStatus(resolved.status, resolved.httpBase);
@@ -110,29 +93,26 @@ export function render({ navigate }) {
const next = await checkServerAvailabilityByKey(field.key, input.value, { const next = await checkServerAvailabilityByKey(field.key, input.value, {
solanaEndpoint: draft.solanaServer, solanaEndpoint: draft.solanaServer,
}); });
if (seq !== checkSeq) return;
applyStatus(next); applyStatus(next);
} }
} catch { } finally {
if (seq === checkSeq) applyStatus('unavailable'); checkButton.disabled = false;
checkButton.textContent = 'Проверить';
} }
}; };
applyStatus(draft.statuses[field.key]); applyStatus(draft.statuses[field.key]);
hint.hidden = field.key !== 'shineServerLogin' || !hint.textContent;
checks.push(runCheck);
checkButton.addEventListener('click', runCheck); checkButton.addEventListener('click', runCheck);
input.addEventListener('input', () => { input.addEventListener('input', () => {
draft[field.key] = input.value; draft[field.key] = input.value;
checkSeq += 1;
applyStatus('idle'); applyStatus('idle');
window.clearTimeout(timers.get(field.key)); window.clearTimeout(timers.get(field.key));
timers.set(field.key, window.setTimeout(() => { timers.set(field.key, window.setTimeout(() => {
void runCheck(); void runCheck();
}, 3000)); }, 3000));
}); });
input.addEventListener('change', () => { input.addEventListener('blur', () => {
void runCheck(); void runCheck();
}); });
input.addEventListener('keydown', (event) => { input.addEventListener('keydown', (event) => {
@@ -142,17 +122,17 @@ export function render({ navigate }) {
} }
}); });
head.append(title, checkButton); controls.append(checkButton, status);
block.append(head, input, hint); block.append(title, input, controls);
body.append(block); body.append(block);
}); });
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'entry-settings__actions'; actions.className = 'auth-footer-actions';
if (isLocalDemoAvailable()) { if (isLocalDemoAvailable()) {
const localDemoButton = document.createElement('button'); const localDemoButton = document.createElement('button');
localDemoButton.className = 'secondary-btn'; localDemoButton.className = 'secondary-btn preauth-local-demo-btn';
localDemoButton.type = 'button'; localDemoButton.type = 'button';
localDemoButton.textContent = 'Открыть локальный тестовый режим'; localDemoButton.textContent = 'Открыть локальный тестовый режим';
localDemoButton.addEventListener('click', () => { localDemoButton.addEventListener('click', () => {
@@ -165,12 +145,18 @@ export function render({ navigate }) {
const serverUiButton = document.createElement('button'); const serverUiButton = document.createElement('button');
serverUiButton.className = 'secondary-btn'; serverUiButton.className = 'secondary-btn';
serverUiButton.type = 'button'; serverUiButton.type = 'button';
serverUiButton.textContent = 'Настроить свой сервер ↗'; serverUiButton.textContent = 'Настроить свой сервер';
serverUiButton.addEventListener('click', () => { serverUiButton.addEventListener('click', () => {
const url = new URL('server-ui.html', window.location.href); const url = new URL('server-ui.html', window.location.href);
window.open(url.toString(), '_blank', 'noopener'); window.open(url.toString(), '_blank', 'noopener');
}); });
const cancelButton = document.createElement('button');
cancelButton.className = 'secondary-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('start-view'));
const saveButton = document.createElement('button'); const saveButton = document.createElement('button');
saveButton.className = 'primary-btn'; saveButton.className = 'primary-btn';
saveButton.type = 'button'; saveButton.type = 'button';
@@ -184,8 +170,7 @@ export function render({ navigate }) {
} }
}); });
actions.prepend(saveButton); actions.append(serverUiButton, cancelButton, saveButton);
actions.append(serverUiButton);
screen.append( screen.append(
createTopBar({ createTopBar({
@@ -197,12 +182,8 @@ export function render({ navigate }) {
); );
screen.cleanup = () => { screen.cleanup = () => {
disposed = true;
timers.forEach((timerId) => window.clearTimeout(timerId)); timers.forEach((timerId) => window.clearTimeout(timerId));
}; };
// Серверы проверяются сразу — зелёный «Доступен» видно без лишних нажатий.
checks.forEach((check) => { void check(); });
return screen; return screen;
} }
+67
View File
@@ -0,0 +1,67 @@
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { bytesToBase64 } from '../services/crypto-utils.js';
import { readShineUserPda } from '../services/shine-user-pda-service.js';
import { KeyRotationClient, KEY_ROTATION_REASONS } from '../services/key-rotation-service.js';
export const pageMeta = { id:'key-rotation-view', title:'Смена ключей', hideToolbar:true };
const POLL_MS=1800;
function h(v){return String(v??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function short(v){const s=String(v||'');return s.length>20?`${s.slice(0,10)}…${s.slice(-8)}`:s;}
function stageText(s){return ({COPYING_CHAIN:'Копирование новой цепочки',CHAIN_READY:'Новая цепочка готова',ROTATING_PDA:'Ожидание Solana',PDA_ROTATED:'PDA обновлена',REBUILDING_SERVER:'Перестройка сервера',WALLET_MIGRATION:'Перенос средств',MESSAGE_MIGRATION:'Перешифрование сообщений',FINALIZING:'Завершение',COMPLETE:'Готово',NONE:'Новая ротация'})[s]||s;}
function input(label,type='password'){const wrap=document.createElement('label');wrap.className='stack';const t=document.createElement('span');t.className='field-label';t.textContent=label;const el=document.createElement('input');el.className='text-input';el.type=type;wrap.append(t,el);return {wrap,el};}
export function render({navigate,chrome}){
const screen=document.createElement('section');screen.className='stack';
chrome?.setTopbar(createTopBar({title:'Смена ключей',back:{label:'←',onClick:()=>navigate('my-blockchain-view')}}));
const root=document.createElement('div');root.className='stack';screen.append(root);
const client=new KeyRotationClient(authService);let disposed=false;let timer=null;let cachedOldBundle=null;let cachedNewBundle=null;
const login=String(state.session.login||'').trim(); const storagePwd=String(state.session.storagePwdInMemory||'').trim();
const solanaEndpoint=String(state.entrySettings.solanaServer||'').trim();
async function derive(password){return authService.derivePasswordKeyBundle(login,password,{onProgress:()=>{}});}
async function verifyOldPassword(bundle){
const pda=await readShineUserPda({login,solanaEndpoint});
if(bundle.rootPair.publicKeyB64!==bytesToBase64(pda.rootKey) || bundle.blockchainPair.publicKeyB64!==bytesToBase64(pda.blockchain.blockchainPublicKey) || bundle.clientPair.publicKeyB64!==bytesToBase64(pda.clientKey)) throw new Error('Текущий пароль не соответствует ключам PDA');
}
function setRoot(...nodes){root.replaceChildren(...nodes);}
function statusCard(s){const card=document.createElement('div');card.className='card stack';card.innerHTML=`<strong>${h(stageText(s.rotationStatus))}</strong><span class="meta-muted">${h(s.sourceBlockchainName||'')} → ${h(s.candidateBlockchainName||'')}</span><span class="meta-muted">Прогресс: ${Number(s.progressCurrent||0)} / ${Number(s.progressTotal||0)}</span>${s.lastError?`<span>${h(s.lastError)}</span>`:''}`;return card;}
function note(text){const c=document.createElement('div');c.className='card';c.textContent=text;return c;}
async function renderNone(){
const head=note('Выберите последний блок, которому вы доверяете. Всё до него будет точно перепубликовано новым blockchain key, после чего добавится TECH_FORK.');
const oldP=input('Текущий пароль'); const newP=input('Новый пароль'); const new2=input('Повторите новый пароль');
const reason=document.createElement('select');reason.className='text-input';for(const r of KEY_ROTATION_REASONS){const o=document.createElement('option');o.value=String(r.code);o.textContent=r.label;reason.append(o);}
const reasonWrap=document.createElement('label');reasonWrap.className='stack';reasonWrap.innerHTML='<span class="field-label">Причина</span>';reasonWrap.append(reason);
const comment=document.createElement('textarea');comment.className='text-input';comment.rows=3;comment.maxLength=1024;comment.placeholder='Необязательный комментарий для истории';
const list=document.createElement('div');list.className='stack';const more=document.createElement('button');more.className='secondary-btn';more.type='button';more.textContent='Показать более ранние блоки';
let selected=null,before=null,loading=false;
async function load(){if(loading)return;loading=true;more.disabled=true;try{const d=await client.getMyBlockchain({beforeBlock:before,limit:100});for(const b of d.blocks||[]){const btn=document.createElement('button');btn.type='button';btn.className='nav-row';btn.innerHTML=`<span class="nav-row__label">#${b.blockNumber} · ${new Date(Number(b.timestampMs)||0).toLocaleString('ru-RU')}</span><span class="nav-row__hint">${short(b.blockHash)}</span>`;btn.addEventListener('click',()=>{selected=b;list.querySelectorAll('button').forEach(x=>x.setAttribute('aria-pressed','false'));btn.setAttribute('aria-pressed','true');});if(selected==null && b.blockNumber===d.tipBlockNumber){selected=b;btn.setAttribute('aria-pressed','true');}list.append(btn);}before=d.nextBeforeBlock;more.hidden=before==null;}finally{loading=false;more.disabled=false;}}
more.addEventListener('click',()=>void load());
const start=document.createElement('button');start.className='primary-btn';start.type='button';start.textContent='Начать смену ключей';
const error=document.createElement('div');error.className='meta-muted';
start.addEventListener('click',async()=>{start.disabled=true;error.textContent='';try{if(!selected)throw new Error('Выберите последний доверенный блок');if(!oldP.el.value||!newP.el.value)throw new Error('Введите текущий и новый пароль');if(newP.el.value!==new2.el.value)throw new Error('Новые пароли не совпадают');if(oldP.el.value===newP.el.value)throw new Error('Новый пароль должен отличаться');cachedOldBundle=await derive(oldP.el.value);await verifyOldPassword(cachedOldBundle);cachedNewBundle=await derive(newP.el.value);const s=await client.start({newRootKey:cachedNewBundle.rootPair.publicKeyB64,newBlockchainKey:cachedNewBundle.blockchainPair.publicKeyB64,newClientKey:cachedNewBundle.clientPair.publicKeyB64,forkFromBlock:selected.blockNumber,forkFromHash:selected.blockHash,reasonCode:Number(reason.value),comment:comment.value});await runCopy(s); }catch(e){error.textContent=e?.message||String(e);start.disabled=false;}});
setRoot(head,oldP.wrap,newP.wrap,new2.wrap,reasonWrap,comment,list,more,start,error);void load();
}
async function askNewPasswordAndCopy(s){
const card=statusCard(s), p=input('Новый пароль');const go=document.createElement('button');go.className='primary-btn';go.textContent='Продолжить копирование';const err=document.createElement('div');err.className='meta-muted';go.addEventListener('click',async()=>{go.disabled=true;try{cachedNewBundle=await derive(p.el.value);if(cachedNewBundle.blockchainPair.publicKeyB64!==s.newBlockchainKey)throw new Error('Этот пароль выводит другой новый blockchain key');await runCopy(s);}catch(e){err.textContent=e?.message||e;go.disabled=false;}});const abort=document.createElement('button');abort.className='secondary-btn';abort.textContent='Прервать смену ключей';abort.addEventListener('click',async()=>{await client.abort();await refresh();});setRoot(card,note('Для продолжения на этом устройстве введите тот же новый пароль, который использовался при запуске.'),p.wrap,go,abort,err);
}
async function runCopy(s){
const card=statusCard(s), info=note('Создаётся новая копия выбранной части цепочки в Arweave/Turbo. Обычные записи аккаунта в это время заблокированы.');setRoot(card,info);
try{await client.copyCandidateChain({rotation:s,newBundle:cachedNewBundle,onProgress:({current,total})=>{card.querySelectorAll('.meta-muted')[1].textContent=`Прогресс: ${current} / ${total}`;}});await client.waitUntilPublished({onProgress:({current,total})=>{card.querySelectorAll('.meta-muted')[1].textContent=`Опубликовано: ${current} / ${total}`;}});await client.finishChain();await refresh();}catch(e){root.append(note(e?.message||String(e)));const retry=document.createElement('button');retry.className='primary-btn';retry.textContent='Повторить / продолжить';retry.addEventListener('click',()=>void refresh());root.append(retry);}
}
async function renderChainReady(s){
const oldP=input('Текущий пароль');const newP=input('Новый пароль');const go=document.createElement('button');go.className='primary-btn';go.textContent='Изменить ключи в Solana';const abort=document.createElement('button');abort.className='secondary-btn';abort.textContent='Прервать смену ключей';const err=document.createElement('div');err.className='meta-muted';
go.addEventListener('click',async()=>{go.disabled=true;try{cachedOldBundle=await derive(oldP.el.value);cachedNewBundle=await derive(newP.el.value);await client.rotatePda({login,solanaEndpoint,oldBundle:cachedOldBundle,newBundle:cachedNewBundle,storagePwd});await refresh();}catch(e){err.textContent=e?.message||String(e);go.disabled=false;}});abort.addEventListener('click',async()=>{await client.abort();await refresh();});setRoot(statusCard(s),note('Новая цепочка полностью готова. Следующий шаг — точка невозврата: старый root разрешит атомарную смену root + client + blockchain fork, а новое PDA подпишет новый blockchain key.'),oldP.wrap,newP.wrap,go,abort,err);
}
function pollView(s){setRoot(statusCard(s),note(s.rotationStatus==='REBUILDING_SERVER'?'Сервер перестраивает текущую рабочую историю по новому fork.':'Ожидаем подтверждение нового состояния PDA в Solana.'));timer=setTimeout(()=>void refresh(),POLL_MS);}
function placeholderView(s,kind){const wallet=kind==='wallet';const text=wallet?'Перевод SOL со старого blockchain-wallet на новый пока не реализован. Этот этап сейчас будет отмечен как NOT_IMPLEMENTED и пропущен.':'Перешифрование старых личных сообщений новым client key пока не реализовано. Этот этап сейчас будет отмечен как NOT_IMPLEMENTED и пропущен.';const go=document.createElement('button');go.className='primary-btn';go.textContent='Продолжить';go.addEventListener('click',async()=>{go.disabled=true;try{const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();}catch(e){go.disabled=false;root.append(note(e?.message||String(e)));}});setRoot(statusCard(s),note(text),go);}
async function refresh(){if(disposed)return;if(timer){clearTimeout(timer);timer=null;}try{const s=await client.status();const st=String(s.rotationStatus||'NONE');if(st==='NONE'){await renderNone();return;}if(st==='COPYING_CHAIN'){if(cachedNewBundle)await runCopy(s);else await askNewPasswordAndCopy(s);return;}if(st==='CHAIN_READY'){await renderChainReady(s);return;}if(['ROTATING_PDA','PDA_ROTATED','REBUILDING_SERVER'].includes(st)){pollView(s);return;}if(st==='WALLET_MIGRATION'){placeholderView(s,'wallet');return;}if(st==='MESSAGE_MIGRATION'){placeholderView(s,'messages');return;}if(st==='FINALIZING'){const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();return;}if(st==='COMPLETE'){setRoot(note('Смена ключей завершена.'));return;}setRoot(note(`Неизвестное состояние ротации: ${st}`));}catch(e){setRoot(note(`Не удалось прочитать состояние смены ключей: ${e?.message||e}`));}}
void refresh();screen.cleanup=()=>{disposed=true;if(timer)clearTimeout(timer);};return screen;
}

Some files were not shown because too many files have changed in this diff Show More