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.LoggerFactory;
import shine.db.dao.BlocksDAO;
import shine.db.dao.KeyRotationCandidateBlocksDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import java.util.ArrayList;
import java.util.List;
@@ -14,6 +16,7 @@ public final class ArweaveBlockPublisherService {
private final ArweaveBlocksConfig cfg;
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
private final KeyRotationCandidateBlocksDAO candidateBlocksDAO = KeyRotationCandidateBlocksDAO.getInstance();
private final ArweaveL1Uploader arweaveUploader;
private final TurboDataItemUploader turboUploader;
@@ -25,13 +28,57 @@ public final class ArweaveBlockPublisherService {
public int runCycle() throws Exception {
if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0;
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
if (candidates.isEmpty()) return 0;
return switch (cfg.publishMode()) {
int maxItems = cfg.publishMaxItems();
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 ARWEAVE -> publishDirectArweave(candidates);
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 {
@@ -57,6 +104,40 @@ public final class ArweaveBlockPublisherService {
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 {
List<byte[]> items = new ArrayList<>();
List<byte[]> ids = new ArrayList<>();
@@ -25,6 +25,10 @@ public final class BodyRecordParser {
&& (v == (CreateChannelBody.VER & 0xFFFF))) {
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(
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). */
public static final short HEADER_COMPAT = 0;
public static final short TECH_CREATE_CHANNEL = 1;
/** Новый fork/ротация ключей: ссылка на родительскую цепочку и точку отката. */
public static final short TECH_FORK = 2;
/* ===================== TEXT (msg_type=1) ===================== */
@@ -53,7 +55,7 @@ public final class MsgSubType {
/**
* REPLY — ответ на сообщение.
* НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32).
* НЕ в линии. Имеет target (toLogin + blockNumber + hash32).
* Может указывать на чужой блокчейн/чужую линию/чужой канал.
*/
public static final short TEXT_REPLY = 20;
@@ -69,7 +71,7 @@ public final class MsgSubType {
/**
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
* Имеет hasLine + target (toLogin + toBlockGlobalNumber + toBlockHash32) + текст комментария.
*/
public static final short TEXT_REPOST = 50;
@@ -1,29 +1,30 @@
package blockchain.body;
import utils.blockchain.BlockchainNameUtil;
/**
* BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
* BodyHasTarget — дополнительный интерфейс для body, которые ссылаются на логическую цель.
*
* Новое правило:
* - toLogin НЕ храним в байтах блока.
* - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN".
* Актуальное правило target:
* - в подписываемых байтах хранится login цели;
* - номер fork/blockchainName в target не хранится;
* - идентичность цели задаётся как login + blockNumber + blockHash.
*
* Все методы могут возвращать null.
* Это позволяет одной и той же логической записи сохранять ссылки после fork,
* если её номер и SHA-256 hash остались прежними.
*/
public interface BodyHasTarget {
/** login цели (nullable). Вычисляется из toBchName(). */
default String toLogin() {
String bch = toBchName();
if (bch == null) return null;
return BlockchainNameUtil.loginFromBlockchainName(bch);
}
/** login цели. Актуальные runtime-body обязаны переопределять этот метод. */
default String toLogin() { return null; }
/** 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();
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
@@ -1,7 +1,6 @@
package blockchain.body;
import blockchain.MsgSubType;
import utils.blockchain.BlockchainNameUtil;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
@@ -12,96 +11,38 @@ import java.util.Objects;
/**
* ConnectionBody — type=3, ver=1 (в заголовке блока).
*
* subType (в заголовке блока) как MsgSubType:
* 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 НЕ ХРАНИМ):
* bodyBytes (BigEndian):
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
*
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber (int32)
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes)
*
* toLogin вычисляется автоматически из toBlockchainName:
* toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName)
* Номер fork/blockchainName в target не хранится.
*/
/**
* =========================================================================
* ПРАВИЛО 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 static final short TYPE = 3;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header
public final short version; // из header
public final short subType;
public final short version;
// line
public final int lineCode;
public final int prevLineNumber;
public final byte[] prevLineHash32;
public final int thisLineNumber;
// payload
public final String toBlockchainName;
public final String toLogin;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
@@ -111,34 +52,25 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
if (!isValidSubType(this.subType)) {
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
}
// минимум:
// 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) {
if (bodyBytes.length < 4 + 4 + 32 + 4 + 1 + 1 + 4 + 32) {
throw new IllegalArgumentException("ConnectionBody too short");
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
int bchLen = Byte.toUnsignedInt(bb.get());
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
byte[] bchBytes = new byte[bchLen];
bb.get(bchBytes);
this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8);
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
@@ -150,39 +82,68 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
byte[] prevLineHash32,
int thisLineNumber,
short subType,
String toBlockchainName,
String toLogin,
int toBlockGlobalNumber,
byte[] toBlockHash32) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
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 (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.lineCode = lineCode;
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.subType = subType;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
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) {
int v = st & 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);
}
@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) {
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;
}
/* ====================== BodyHasLine ====================== */
@Override public int lineCode() { return lineCode; }
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
@Override public int lineSeq() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */
@Override public String toBchName() { return toBlockchainName; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@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
* 2 = UNLIKE
*
* bodyBytes (BigEndian), новый формат:
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* bodyBytes (BigEndian):
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes)
*
* Номер fork/blockchainName в target не хранится.
* ЛИНИИ НЕТ.
*/
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 final short subType; // из header
public final short version; // из header
public final short subType;
public final short version;
public final String toBlockchainName;
public final String toLogin;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
@@ -49,40 +50,37 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
if (!isSupportedSubType(this.subType)) {
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
}
// минимум: nameLen[1]+name[1]+global[4]+hash[32]
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
public ReactionBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
this.subType = MsgSubType.REACTION_LIKE;
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 (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = toBlockchainName;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
}
@@ -91,37 +89,30 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
public ReactionBody check() {
if (!isSupportedSubType(subType))
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
if (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("toBlockchainName is blank");
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[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameBytes.length == 0 || nameBytes.length > 255)
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginBytes.length == 0 || loginBytes.length > 255)
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
int cap = 1 + nameBytes.length + 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameBytes.length);
bb.put(nameBytes);
ByteBuffer bb = ByteBuffer.allocate(1 + loginBytes.length + 4 + 32).order(ByteOrder.BIG_ENDIAN);
bb.put((byte) loginBytes.length);
bb.put(loginBytes);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
return bb.array();
}
/* ====================== BodyHasTarget ====================== */
@Override public String toBchName() { return toBlockchainName; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -13,11 +13,11 @@ import java.util.Objects;
/**
* StatusActionBody — type=5, ver=1.
*
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
* Все STATUS_ACTION имеют target на конкретный логический блок и опциональный текст-пояснение.
*
* Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
@@ -31,7 +31,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
public final short subType;
public final short version;
public final String toBlockchainName;
public final String toLogin;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
@@ -51,33 +51,32 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toLoginLen is 0");
ensureMin(bb, loginLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
ensureNoTail(bb, "StatusActionBody");
}
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
public StatusActionBody(short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null");
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 (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.subType = subType;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
@@ -85,16 +84,10 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
@Override
public StatusActionBody check() {
if (!isSupportedSubType(subType)) {
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
}
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("STATUS_ACTION toLogin is blank");
if (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 (message == null) throw new IllegalArgumentException("message is null");
return this;
}
@@ -104,15 +97,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
}
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginUtf8.length == 0 || loginUtf8.length > 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);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
@@ -120,7 +112,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
return bb.array();
}
@Override public String toBchName() { return toBlockchainName; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -141,14 +133,11 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) return "";
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException e) {
@@ -33,23 +33,13 @@ import java.util.Objects;
* [2] textLenBytes (uint16)
* [N] text UTF-8
*
* EDIT_POST:
* EDIT_POST / REPOST:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [N] text UTF-8
*
* REPOST:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32
* [2] textLenBytes (uint16)
@@ -72,7 +62,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
public final int thisLineNumber;
// 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 byte[] toBlockHash32; // nullable для POST
@@ -116,29 +106,19 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.thisLineNumber = bb.getInt();
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// нужен target
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target");
int tgtNum = bb.getInt();
byte[] tgtHash = new byte[32];
bb.get(tgtHash);
this.toBlockchainName = null;
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);
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TEXT target missing");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
ensureMin(bb, loginLen + 4 + 32 + 2, "TEXT target payload too short");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
} else {
this.toBlockchainName = null;
this.toLogin = null;
this.toBlockGlobalNumber = null;
this.toBlockHash32 = null;
}
@@ -158,7 +138,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
short subType,
Integer toBlockGlobalNumber,
byte[] toBlockHash32,
String toBlockchainName,
String toLogin,
String message) {
Objects.requireNonNull(message, "message == null");
@@ -189,27 +169,29 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.thisLineNumber = thisLineNumber;
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = null;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
Objects.requireNonNull(toLogin, "toLogin == null");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = toBlockchainName;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else {
this.toBlockchainName = null;
this.toLogin = null;
this.toBlockGlobalNumber = null;
this.toBlockHash32 = null;
}
@@ -240,13 +222,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
if (toBlockchainName != null)
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName");
if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("EDIT_POST toLogin is blank");
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
if (message == null || message.isBlank())
throw new IllegalArgumentException("REPOST message is blank");
if (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("REPOST toBlockchainName is blank");
if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("REPOST toLogin is blank");
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
@@ -259,7 +241,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
} else if (message == 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");
}
@@ -284,19 +266,14 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
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 {
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (toLogin == null) throw new IllegalArgumentException("target missing toLogin");
byte[] nameUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
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 (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32");
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("target missing toBlockGlobalNumber");
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;
}
@@ -307,13 +284,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
}
@@ -331,7 +305,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
@Override public int lineSeq() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */
@Override public String toBchName() { return toBlockchainName; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -11,14 +11,11 @@ import java.util.Arrays;
import java.util.Objects;
/**
* TextRatingBody — type=1, ver=1.
*
* subType:
* - RATING (30)
* TextRatingBody — type=1, ver=1, subType=30.
*
* Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
@@ -32,7 +29,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
public final short subType;
public final short version;
public final String toBlockchainName;
public final String toLogin;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
@@ -52,33 +49,32 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("RATING toLoginLen is 0");
ensureMin(bb, loginLen + 4 + 32 + 2, "RATING payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
ensureNoTail(bb, "TextRatingBody");
}
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
public TextRatingBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == 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 (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
this.subType = MsgSubType.TEXT_RATING;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
@@ -86,16 +82,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
@Override
public TextRatingBody check() {
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("RATING toBlockchainName is blank");
}
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF))
throw new IllegalArgumentException("Bad RATING subType");
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("RATING toLogin is blank");
if (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 (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
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 > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
}
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginUtf8.length == 0 || loginUtf8.length > 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);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
@@ -122,7 +112,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
return bb.array();
}
@Override public String toBchName() { return toBlockchainName; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -130,14 +120,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
@@ -17,84 +17,56 @@ import java.util.Objects;
* - REPLY (20)
* - EDIT_REPLY (21)
*
* Форматы bodyBytes (BigEndian):
*
* REPLY:
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* Оба subtype используют одинаковый target-формат:
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*
* EDIT_REPLY:
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [N] text UTF-8
* Для EDIT_REPLY text может быть пустым (логическое удаление).
*/
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header
public final short version; // (=1)
public final short subType;
public final short version;
// target
public final String toBlockchainName; // nullable для EDIT_REPLY
public final String toLogin;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; // 32
// text
public final byte[] toBlockHash32;
public final String message;
public TextReplyBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
}
int st = this.subType & 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);
}
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)) {
// минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2]
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("TextReplyBody toLoginLen is 0");
ensureMin(bb, loginLen + 4 + 32 + 2, "TextReplyBody payload too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
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);
}
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
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));
ensureNoTail(bb, "TextReplyBody");
@@ -103,11 +75,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public TextReplyBody(short subType,
int toBlockGlobalNumber,
byte[] toBlockHash32,
String toBlockchainName,
String toLogin,
String message) {
Objects.requireNonNull(message, "message == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(toLogin, "toLogin == null");
int st = subType & 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()) {
throw new IllegalArgumentException("message is blank");
}
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
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.version = VER;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
}
@@ -143,23 +105,14 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
if (toBlockGlobalNumber < 0)
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 invalid");
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (message == null || message.isBlank())
throw new IllegalArgumentException("Text message is blank");
if (toBlockchainName == null || toBlockchainName.isBlank())
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");
if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank");
} else if (message == null) {
throw new IllegalArgumentException("EDIT_REPLY message is null");
}
return this;
}
@@ -167,47 +120,27 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
throw new IllegalArgumentException("Text payload is empty");
}
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
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);
if (nameUtf8.length == 0 || nameUtf8.length > 255)
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
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);
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
/* ====================== BodyHasTarget ====================== */
@Override public String toBchName() { return toBlockchainName; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@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);
}
/* ====================== helpers ====================== */
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) {
@@ -224,14 +155,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
throw new IllegalArgumentException(fieldName + " is empty");
}
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
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_24 = 24;
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_MIGRATION_V2_RESOURCE = "postgres/migration_v2.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_V24_RESOURCE = "postgres/migration_v24.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() {}
@@ -236,6 +240,14 @@ public final class DatabaseInitializer {
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
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 {
String sql = """
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 {
String sql = """
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)
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
schema_version = EXCLUDED.schema_version,
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)
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)
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;
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_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_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.entyties.Net_GetUser_Request;
@@ -191,6 +208,16 @@ public final class JsonHandlerRegistry {
// --- blockchain ---
Map.entry("AddBlock", new Net_AddBlock_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 ---
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
@@ -283,6 +310,16 @@ public final class JsonHandlerRegistry {
// --- blockchain ---
Map.entry("AddBlock", Net_AddBlock_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 ---
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
@@ -98,6 +98,18 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
lock.lock();
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(
blockchainName,
req.getBlockNumber(), // старое поле, пока оставляем
@@ -152,6 +164,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
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) {
if (code == null) return "Ошибка добавления блока";
return switch (code) {
@@ -184,6 +213,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
case "key_rotation_in_progress" -> "Сейчас выполняется смена ключей; обычные новые блоки временно запрещены";
case "key_rotation_check_failed" -> "Не удалось проверить состояние смены ключей";
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";
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
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
WHERE login = ? AND msg_type = ?
ORDER BY block_number
@@ -132,9 +133,9 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
item.setLikedByMe(false);
item.setRepliesCount(0);
item.setRatingsCount(0);
item.setTargetBlockchainName(statusBody.toBchName());
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber());
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes()));
item.setTargetBlockchainName(rs.getString("to_bch_name"));
item.setTargetBlockNumber((Integer) rs.getObject("to_block_number"));
item.setTargetBlockHash(ChannelsReadSupport.toHex(rs.getBytes("to_block_hash")));
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
c,
@@ -148,7 +149,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
item.setVersionsTotal(versions.size());
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);
}
}
@@ -215,7 +217,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
return;
}
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
WHERE bch_name = ? AND block_number = ?
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 formatMinor = raw[6] & 0xFF;
if (formatMajor != 1 || formatMinor != 2) {
if (formatMajor != 1) {
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(
@@ -1,5 +1,6 @@
package sync.storage.postgres;
import sync.util.Base58Util;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
@@ -272,6 +273,11 @@ public final class PostgresStorageRepository
snapshots
);
reconcileConfirmedKeyRotations(
connection,
snapshots
);
upsertSyncState(
connection,
newState
@@ -305,6 +311,11 @@ public final class PostgresStorageRepository
snapshots
);
reconcileConfirmedKeyRotations(
connection,
snapshots
);
upsertSyncState(
connection,
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(
PreparedStatement statement,
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;
import server.keyrotation.KeyRotationRebuildScheduler;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletContextHandler;
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.
ArweaveBlockPublisherScheduler.startOrLog();
ArweaveBlockSyncScheduler.startOrLog();
KeyRotationRebuildScheduler.startOrLog();
// ============================================================
// 2) Запуск Jetty WS
@@ -4,7 +4,8 @@ import blockchain.MsgSubType;
import blockchain.body.ConnectionBody;
import blockchain.body.CreateChannelBody;
import blockchain.body.HeaderBody;
import blockchain.body.TextBody;
import blockchain.body.TextLineBody;
import blockchain.body.TextReplyBody;
import shine.db.DbController;
import test.it.blockchain.AddBlockSender;
import test.it.blockchain.ChainState;
@@ -27,7 +28,7 @@ import static org.junit.jupiter.api.Assertions.*;
* CONNECTION (type=3):
* - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber)
* - всегда имеет target:
* toBlockchainName + toBlockGlobalNumber + toBlockHash32
* toLogin + toBlockGlobalNumber + toBlockHash32
*
* Правило target для связей/подписок:
* - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0)
@@ -88,12 +89,12 @@ public class IT_03_AddBlock_NoAuth {
// POST в канал "0"
{
var ln = st1.nextTextLineByRoot(root0);
sender1.send(new TextBody(
MsgSubType.TEXT_POST,
sender1.send(new TextLineBody(
root0,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"U1: story/post in channel 0",
null, null, null
MsgSubType.TEXT_POST,
null, null, null,
"U1: story/post in channel 0"
), t);
}
@@ -148,12 +149,12 @@ public class IT_03_AddBlock_NoAuth {
byte[] newsPost0Hash;
{
var ln = st1.nextTextLineByRoot(newsRootBlock);
sender1.send(new TextBody(
MsgSubType.TEXT_POST,
sender1.send(new TextLineBody(
newsRootBlock,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"U1: News post #0",
null, null, null
MsgSubType.TEXT_POST,
null, null, null,
"U1: News post #0"
), t);
newsPost0Block = st1.lastBlockNumber();
@@ -164,26 +165,26 @@ public class IT_03_AddBlock_NoAuth {
// POST #1 в канал "News"
{
var ln = st1.nextTextLineByRoot(newsRootBlock);
sender1.send(new TextBody(
MsgSubType.TEXT_POST,
sender1.send(new TextLineBody(
newsRootBlock,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"U1: News post #1",
null, null, null
MsgSubType.TEXT_POST,
null, null, null,
"U1: News post #1"
), t);
}
// EDIT_POST (в линии канала) -> target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName)
// EDIT_POST -> target на ОРИГИНАЛЬНЫЙ POST по login + number + hash
{
var ln = st1.nextTextLineByRoot(newsRootBlock);
sender1.send(new TextBody(
MsgSubType.TEXT_EDIT_POST,
sender1.send(new TextLineBody(
newsRootBlock,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"U1: News post #0 (EDIT)",
null,
MsgSubType.TEXT_EDIT_POST,
newsPost0Block,
newsPost0Hash
newsPost0Hash,
u1,
"U1: News post #0 (EDIT)"
), t);
}
@@ -206,17 +207,17 @@ public class IT_03_AddBlock_NoAuth {
// 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2)
sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW,
bch2, u2HeaderBlock, u2HeaderHash,
u2, u2HeaderBlock, u2HeaderHash,
"U1 follows U2 (target=U2 HEADER)", t);
// 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1)
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
bch1, newsRootBlock, newsRootHash,
u1, newsRootBlock, newsRootHash,
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
// 3) U2 подписался на второй канал U1 "Updates"
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
bch1, updatesRootBlock, updatesRootHash,
u1, updatesRootBlock, updatesRootHash,
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
assertEquals(2, countConnectionsByOwner(u2, u1),
@@ -230,30 +231,31 @@ public class IT_03_AddBlock_NoAuth {
// 4) FRIEND взаимно (на HEADER)
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch2, u2HeaderBlock, u2HeaderHash,
u2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: FRIEND", t);
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch1, u1HeaderBlock, u1HeaderHash,
u1, u1HeaderBlock, u1HeaderHash,
"U2 -> U1: FRIEND", t);
// 5) CONTACT несколько
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
bch2, u2HeaderBlock, u2HeaderHash,
u2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: CONTACT", t);
sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT,
bch1, u1HeaderBlock, u1HeaderHash,
u1, u1HeaderBlock, u1HeaderHash,
"U2 -> U1: CONTACT", t);
// =========================
// USER2 REPLY (ответ в чужой канал)
// =========================
{
sender2.send(TextBody.newReply(
bch1,
sender2.send(new TextReplyBody(
MsgSubType.TEXT_REPLY,
newsPost0Block,
newsPost0Hash,
u1,
"U2: reply to U1 News post #0 (cross-chain)"
), t);
}
@@ -273,12 +275,12 @@ public class IT_03_AddBlock_NoAuth {
// U1 -> U3: CONTACT
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
bch3, u3HeaderBlock, u3HeaderHash,
u3, u3HeaderBlock, u3HeaderHash,
"U1 -> U3: CONTACT", t);
// 6) U2 отписывается только от News
sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW,
bch1, newsRootBlock, newsRootHash,
u1, newsRootBlock, newsRootHash,
"U2 unfollows U1 channel 'News'", t);
assertEquals(1, countConnectionsByOwner(u2, u1),
@@ -292,7 +294,7 @@ public class IT_03_AddBlock_NoAuth {
// 7) U1 убирает U2 из контактов (UNCONTACT)
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
bch2, u2HeaderBlock, u2HeaderHash,
u2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: UNCONTACT", t);
r.ok("IT_03 сценарий блоков + connections выполнен");
@@ -313,7 +315,7 @@ public class IT_03_AddBlock_NoAuth {
private static void sendConnection(AddBlockSender sender,
ChainState st,
short subType,
String toBlockchainName,
String toLogin,
int toBlockNumber,
byte[] toBlockHash32,
String logNote,
@@ -321,7 +323,7 @@ public class IT_03_AddBlock_NoAuth {
if (TestConfig.DEBUG()) {
TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF)
+ " to=" + toBlockchainName
+ " to=" + toLogin
+ " targetBlock=" + toBlockNumber
+ " note=" + logNote);
}
@@ -330,14 +332,14 @@ public class IT_03_AddBlock_NoAuth {
// КОНСТРУКТОР ИЗ ТВОЕГО КОДА:
// 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(
0, // lineCode для connection линии
ln.prevLineNumber,
ln.prevLineHash32,
ln.thisLineNumber,
subType,
toBlockchainName,
toLogin,
toBlockNumber,
toBlockHash32
), timeout);
@@ -151,7 +151,7 @@ public final class SeedDataPopulationHelper {
line.prevLineHash32,
line.thisLineNumber,
relationSubType,
bch(to),
to,
0,
targetHeaderHash
), timeout);
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.14.1
client.version=1.14.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`.
Миграция 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_prev_hash` — нарушена SHiNE hash chain;
- `bad_block_bytes` — DataItem/Frame не парсится.
- `key_rotation_in_progress` (HTTP/status `423`) — для пользователя уже запущена смена ключей; обычный `AddBlock` временно запрещён до завершения/отмены ротации;
- `key_rotation_check_failed` — сервер не смог проверить локальный rotation status и отклонил запись fail-closed.
## Storage/publish
+8
View File
@@ -31,6 +31,14 @@
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
| `AddBlock` | `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 без криптографической проверки |
| `Ping` | `05_Technical_Requests_API.md` | keep-alive |
| `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=2` — REACTION: LIKE/UNLIKE.
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
+37 -1
View File
@@ -12,7 +12,43 @@ TECH-тип покрывает системные записи цепочки.
- создание нового канала;
- хранит 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`
- ответ на сообщение;
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
- target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
4. `subType=21` — `TEXT_EDIT_REPLY`
- редактирование ответа;
@@ -23,7 +23,7 @@ TEXT-тип хранит сообщения, материалы и редакт
5. `subType=30` — `TEXT_RATING`
- target-based отзыв на конкретный блок;
- содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
- содержит target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
- не является сообщением линии канала.
6. `subType=50` — `TEXT_REPOST`
@@ -57,6 +57,20 @@ TEXT-тип хранит сообщения, материалы и редакт
Подробная спецификация: [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_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
+16 -2
View File
@@ -4,11 +4,25 @@
1. `subType=1` — `REACTION_LIKE`
- лайк на целевой блок;
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
- хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
2. `subType=2` — `REACTION_UNLIKE`
- снятие лайка с целевого блока;
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
- хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
## Назначение
- реакция на текстовые сообщения (и потенциально другие 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
- 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
+3 -3
View File
@@ -36,8 +36,8 @@
Все `STATUS_ACTION` используют один и тот же бинарный body-формат:
```text
[1] toBlockchainNameLen (uint8)
[N] toBlockchainName UTF-8
[1] toLoginLen (uint8)
[N] toLogin UTF-8
[4] toBlockGlobalNumber
[32] toBlockHash32
[2] textLenBytes (uint16)
@@ -46,7 +46,7 @@
Где:
- `toBlockchainName` — блокчейн, в котором находится целевой материал;
- `toLogin` — login владельца целевого блока; номер fork в target не хранится;
- `toBlockGlobalNumber` — номер целевого блока;
- `toBlockHash32` — хэш целевого блока;
- `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
+19 -19
View File
@@ -2,10 +2,10 @@
> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.**
> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся
> все ключи (root, blockchain, device/Solana, homeserver) — формулами, байт-в-байт.
> все ключи (root, blockchain, client, homeserver) — формулами, байт-в-байт.
> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула
> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь
> dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
> blockchain key ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
> Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура).
> Здесь — только механика. Документ намеренно краткий.
@@ -55,29 +55,29 @@ seed(32) = SHA-256(material)
| Ключ | Суффикс | Назначение (кратко) |
|------|---------|---------------------|
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). |
| device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. |
| blockchain | `blockchain.key` | Подписывает пользовательские блоки/ANS-104 DataItem и является текущим Solana-wallet/fee payer. |
| client | `client.key` | Общий клиентский ключ для DM/устройств и derivation отдельного Arweave SAWD-кошелька; не является текущим Solana-wallet. |
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
Полные роли каждого ключа — в `docs/Keys/README.md`.
---
## 3. Solana-ключ
## 3. Solana-кошелёк и authority
Отдельного «солана-ключа» нет. На Solana работают два ключа:
Отдельного «solana.key» нет. В актуальной модели Solana-wallet пользователя — **активный `blockchain.key`**:
- **`client.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(clientPub)`.
Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`.
Пополнять SOL нужно именно на этот адрес.
- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer.
- адрес кошелька = `base58(activeBlockchainPub)`;
- им оплачивается регистрация (`create_user_pda`) и обычные `update_user_pda`;
- обычное обновление PDA авторизуется активным blockchain key;
- новый 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
(«create/update оплачиваются с `client_key`», «root_key — не fee payer»).
`root.key` не является fee payer. Он используется как холодное дополнительное разрешение только для полной ротации root + client + blockchain fork.
Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью
(`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый
кошелёк и плательщик комиссий**. Полное описание ролей — `docs/Keys/README.md`.
`client.key` больше не используется как Solana-wallet/fee payer. Он остаётся клиентским криптографическим ключом (DM/устройства) и входом в отдельный протокол derivation Arweave-кошелька.
При 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).
### Solana-ключ / адрес кошелька (UI)
- `shine-UI/js/pages/registration-payment-view.js` — `deriveUserWalletAddress`: адрес = `base58(clientPub)` (~113).
- `shine-UI/js/pages/topup-view.js` — `clientWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.clientPair`.
Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `client.key`, мимо `masterSecret`) удалён.
- `shine-UI/js/pages/registration-payment-view.js` — адрес пополнения = `base58(blockchainPub)`.
- `shine-UI/js/pages/topup-view.js` — тот же адрес из `preGeneratedKeyBundle.blockchainPair`.
- `shine-UI/js/services/solana-wallet-service.js` — текущий пользовательский Solana-wallet загружается из сохранённого `blockchainKey`.
### Деривация ключей (прошивка ESP32)
- `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. Этот документ — источник истины по деривации секрета и ключей.
2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа,
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь dev-ключ ↔ Solana-адрес —
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь blockchain key ↔ Solana-адрес —
**обязательно** отражать здесь в том же изменении.
3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма.
4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости.
+11 -14
View File
@@ -8,9 +8,9 @@
В SHiNE у пользователя есть несколько уровней ключей:
- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`).
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
- `root key` - холодный recovery-ключ: при полной ротации дополнительно разрешает замену root + client + blockchain fork. Это не кошелёк.
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн и текущий Solana-wallet/fee payer пользователя.
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков и DM; Solana-wallet им больше не является.
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
@@ -21,16 +21,13 @@
Назначение:
- регистрация пользователя в Solana;
- создание и обновление пользовательской PDA-записи;
- вызов критически важных Solana-функций;
- изменение главных настроек пользователя;
- управление остальными ключами;
- подтверждение операций, которые должны иметь максимальный уровень доверия.
- холодное recovery-разрешение для полной смены ключей;
- подтверждение атомарной ротации `root + client + новый blockchain fork`;
- восстановительные сценарии повышенного уровня доверия.
`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`
@@ -40,7 +37,8 @@
- подпись записей в персональном блокчейне пользователя;
- подтверждение действий, которые должны попасть в SHiNE-блокчейн;
- разделение полномочий между главным Solana-ключом и ключом ежедневной записи.
- обычные обновления пользовательской PDA;
- текущий Solana-wallet/fee payer (`base58(active blockchain public key)`).
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
@@ -59,7 +57,6 @@
- повседневные входящие и исходящие личные сообщения;
- звонки и связанные с ними сообщения;
- self-messages, то есть внутренние сообщения пользователя самому себе;
- мелкие Solana-расходы на текущие операции;
- derivation 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/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
@@ -584,3 +584,28 @@ SYNC_POLL_INTERVAL_SECONDS=300
- server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес.
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;
- `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 активен;
- необязательный `ServerProfileBlock` — в 1.2 ровно один адрес сервера;
- необязательный `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_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`.
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя или сервера.
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`.
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с активного `blockchainKey`.
- В UI это означает, что Solana fee payer всегда берётся из текущего активного `blockchain key` пользователя или сервера.
- `rootKey` не оплачивает транзакцию; он дополнительно авторизует только полную ротацию ключей.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес текущего `blockchainKey`.
## Важно
- `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-классификации логина.
- При новом devnet deploy планируется использовать те же program keypair, чтобы `program id` на devnet совпадали с mainnet.
+14 -16
View File
@@ -6,7 +6,7 @@
name="viewport"
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="/" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
@@ -45,7 +45,7 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
<script>
(function attachStylesWithBuildHash() {
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) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
@@ -208,19 +208,19 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
const formatReportText = (report) => {
const lines = [
`Ошибка: ${report.title || report.kind || 'unknown'}`,
`Описание: ${report.message || '–'}`,
`Окно: ${report.pageTitle || '–'}`,
`Маршрут: ${report.route || '–'}`,
`URL: ${report.href || '–'}`,
`Видимость: ${report.pageVisibility || '–'}`,
`Описание: ${report.message || '—'}`,
`Окно: ${report.pageTitle || '—'}`,
`Маршрут: ${report.route || '—'}`,
`URL: ${report.href || '—'}`,
`Видимость: ${report.pageVisibility || '—'}`,
`Время: ${new Date(Number(report.clientTs || Date.now())).toISOString()}`,
`UA: ${report.userAgent || '–'}`,
`Экран: ${report.viewport ? `${report.viewport.width}x${report.viewport.height} @${report.viewport.dpr || 1}x` : '–'}`,
`Монитор: ${report.screenInfo ? `${report.screenInfo.width}x${report.screenInfo.height}` : '–'}`,
`Источник: ${report.sourceUrl || '–'}`,
`Строка: ${Number.isFinite(report.lineNumber) ? report.lineNumber : '–'}`,
`Колонка: ${Number.isFinite(report.columnNumber) ? report.columnNumber : '–'}`,
`Тип причины: ${report.reasonType || '–'}`,
`UA: ${report.userAgent || '—'}`,
`Экран: ${report.viewport ? `${report.viewport.width}x${report.viewport.height} @${report.viewport.dpr || 1}x` : '—'}`,
`Монитор: ${report.screenInfo ? `${report.screenInfo.width}x${report.screenInfo.height}` : '—'}`,
`Источник: ${report.sourceUrl || '—'}`,
`Строка: ${Number.isFinite(report.lineNumber) ? report.lineNumber : '—'}`,
`Колонка: ${Number.isFinite(report.columnNumber) ? report.columnNumber : '—'}`,
`Тип причины: ${report.reasonType || '—'}`,
];
if (report.stack) {
lines.push('Stack:', report.stack);
@@ -418,8 +418,6 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
} catch {}
};
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 || ''}`, {
kind: 'window_error',
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 { initCallUiOverlay } from './services/call-ui-service.js';
import { showToast } from './services/channels-ux.js';
import { KeyRotationClient } from './services/key-rotation-service.js';
import {
handleCallPushAction,
handleIncomingCallInvite,
@@ -73,6 +74,8 @@ import * as profileEditView from './pages/profile-edit-view.js';
import * as profilesView from './pages/profiles-view.js';
import * as walletView from './pages/wallet-view.js?v=202609260900';
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 developerSettingsView from './pages/developer-settings-view.js';
import * as advancedSettingsView from './pages/advanced-settings-view.js';
@@ -142,6 +145,8 @@ const routes = {
'profiles-view': profilesView,
'wallet-view': walletView,
'settings-view': settingsView,
'my-blockchain-view': myBlockchainView,
'key-rotation-view': keyRotationView,
'access-servers-view': accessServersView,
'developer-settings-view': developerSettingsView,
'advanced-settings-view': advancedSettingsView,
@@ -326,7 +331,7 @@ function syncViewportMetrics() {
0,
stableViewportHeightPx - Math.min(currentLayoutHeightPx, visualBottomPx),
);
// Address bar Android обычно даёт небольшую дельту; клавиатура – заметно больше.
// Address bar Android обычно даёт небольшую дельту; клавиатура — заметно больше.
const keyboardOffsetPx = textEntryFocused && rawKeyboardOffsetPx >= 100
? rawKeyboardOffsetPx
: 0;
@@ -433,7 +438,7 @@ const DEFAULT_SHELL_MODE = Object.freeze({
contentUnderTopbar: true,
contentUnderBottom: false,
scrollContainer: 'screen',
scrollbar: 'hidden', // как в мобильных приложениях: прокрутка есть, полосы нет и контент не прыгает
scrollbar: 'auto',
});
function normalizeShellMode(mode = {}, showAppChrome = true) {
@@ -1273,7 +1278,7 @@ window.addEventListener('error', (event) => {
},
});
// Кросс-ориджин ошибки приходят как «Script error.» без файла/строки/стека –
// Кросс-ориджин ошибки приходят как «Script error.» без файла/строки/стека —
// алертом такие не показываем (в лог и captureClientError выше уже записаны).
const isOpaqueCrossOriginError = String(event.message || '') === 'Script error.'
&& !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() {
syncTrackedRouteHistory(window.location.pathname || '/');
const route = getRoute();
@@ -1566,6 +1586,7 @@ async function init() {
setSessionAuthorizedHandler(() => {
void ensureSessionRuntimeStarted();
void processPendingCallPushActionIfPossible();
void redirectToActiveKeyRotationIfNeeded();
});
if ('serviceWorker' in navigator) {
@@ -9,7 +9,6 @@ import {
import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js';
import { escapeHtml, formatBytes, getAttachmentKind, normalizeAttachment } from '../services/attachment-format.js';
import { getStoredSolanaWalletChoices } from '../services/solana-wallet-service.js';
import { iconHtml } from './ui-icon.js';
import {
estimateTurboUploadPrice,
formatTurboCredits,
@@ -126,7 +125,7 @@ export function markArweaveAttachmentPlaced(login, attachment) {
export function formatArweaveHistoryTime(ms) {
const value = Number(ms || 0);
if (!Number.isFinite(value) || value <= 0) return '–';
if (!Number.isFinite(value) || value <= 0) return '—';
const diffMs = Date.now() - value;
if (diffMs >= 0 && diffMs < RECENT_UPLOAD_MS) {
const minutes = Math.max(1, Math.floor(diffMs / 60000));
@@ -137,7 +136,7 @@ export function formatArweaveHistoryTime(ms) {
export function formatArweaveHistoryDateTime(ms) {
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');
}
@@ -291,7 +290,7 @@ export function openArweaveAttachmentManager({
mode = 'attachment',
historyPurpose = '',
uploadTransport = 'turbo',
turboKeySource = 'client',
turboKeySource = 'blockchain',
dialogTitle = '',
uploadButtonLabel = '',
initialFile = null,
@@ -407,20 +406,9 @@ export function openArweaveAttachmentManager({
turboKeyChoices = await getStoredSolanaWalletChoices({
login: cleanLogin,
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))) {
selectedTurboKeySource = String(
turboKeyChoices.find((item) => item.keySource === 'client')?.keySource
|| turboKeyChoices[0]?.keySource
|| 'client'
);
selectedTurboKeySource = String(turboKeyChoices[0]?.keySource || 'blockchain');
}
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
} catch (error) {
@@ -431,10 +419,10 @@ export function openArweaveAttachmentManager({
const renderTurboMeta = (mainMetaEl, previewMetaEl) => {
if (!mainMetaEl || !previewMetaEl) return;
const turboAddress = String(balanceInfo?.address || priceInfo?.address || '').trim();
const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '–';
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '–';
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '–';
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '–';
const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '—';
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—';
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—';
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—';
const isFreeSize = selectedFile && combinedUploadSize() <= getFreeTurboUploadBytesLimit();
const summary = selectedFile
? `Размер ${escapeHtml(fileSize)} · ${isFreeSize
@@ -445,10 +433,10 @@ export function openArweaveAttachmentManager({
${summary ? `<div class="ar-attachment-summary">${summary}</div>` : ''}
<details class="ar-attachment-details">
<summary>Подробнее</summary>
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '–'))}</div>
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
<div>Баланс Turbo: ${escapeHtml(turboCredits)}</div>
<div>SOL на этом ключе: ${solBalance}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '–')}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
<div>Цена Turbo: ${escapeHtml(uploadPrice)}</div>
<div>Бесплатно до ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))}</div>
</details>
@@ -459,8 +447,8 @@ export function openArweaveAttachmentManager({
<div><strong>Файл превью</strong></div>
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '–')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '–')}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '—')}</div>
`;
}
};
@@ -521,7 +509,7 @@ export function openArweaveAttachmentManager({
mainMetaEl.innerHTML = `
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile?.name || 'file')}</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(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
`;
@@ -531,8 +519,8 @@ export function openArweaveAttachmentManager({
<div><strong>Файл превью</strong></div>
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '–')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '–')}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}</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 turboMode = isTurboUpload();
const titleText = effectiveDialogTitle
|| (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'));
|| (turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')));
const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить');
const canShowHistory = allowHistorySelection;
const canShowExisting = allowExistingTxInput;
@@ -634,11 +622,11 @@ export function openArweaveAttachmentManager({
<div class="modal-card stack ar-attachment-manager-card">
<div class="ar-attachment-manager-head">
<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 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-arweave">Свой кошелёк</button>
<button class="tab-btn" role="radio" aria-checked="${!turboMode}" type="button" data-action="switch-arweave">Свой кошелёк Arweave</button>
</div>
${turboMode
? `
+4 -17
View File
@@ -28,7 +28,6 @@ export function openChannelEditor({
submitLabel = 'Ответить',
placeholder = 'Напишите ответ',
context = null,
destination = '',
key = 'reply',
extraControl = null,
initialText = '',
@@ -39,7 +38,7 @@ export function openChannelEditor({
isActive = () => true,
} = {}) {
if (!state.session.isAuthorized) {
openAuthRequiredModal({ title: 'Нужен аккаунт', text: 'Чтобы публиковать и отвечать, войдите или зарегистрируйтесь.' });
openAuthRequiredModal({ title: 'Войдите, чтобы написать', text: 'Для публикации и ответа нужен активный профиль.' });
return null;
}
const root = document.getElementById('modal-root');
@@ -78,15 +77,11 @@ export function openChannelEditor({
closeButton.type = 'button';
closeButton.className = 'icon-btn channel-editor__close';
closeButton.setAttribute('aria-label', 'Закрыть редактор');
closeButton.innerHTML = iconHtml('close');
closeButton.textContent = '×';
const heading = document.createElement('h2');
heading.id = `${id}-title`;
heading.textContent = title;
// Закрытие не теряет текст: черновик хранится до отправки
const draftMark = document.createElement('span');
draftMark.className = 'channel-editor__draft';
draftMark.textContent = 'Черновик';
header.append(closeButton, heading, draftMark);
header.append(closeButton, heading, document.createElement('span'));
const body = document.createElement('div');
body.className = 'channel-editor__body';
@@ -135,17 +130,9 @@ export function openChannelEditor({
const author = document.createElement('div');
author.className = 'channel-editor__author';
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');
authorName.textContent = login || 'Гость';
authorText.append(authorName);
if (destination) {
const where = document.createElement('small');
where.textContent = destination;
authorText.append(where);
}
author.append(authorText);
author.append(authorName);
body.append(author);
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 { iconHtml } from './ui-icon.js';
export function attachMessageMenu(card, head, items) {
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'icon-btn channel-message-more';
trigger.innerHTML = iconHtml('more');
trigger.textContent = '⋯';
trigger.setAttribute('aria-label', 'Действия сообщения');
head.append(trigger);
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);
return `
<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>` : ''}
</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') {
openAuthRequiredModal({
title: 'Нужен аккаунт',
text: 'Чтобы писать личные сообщения, войдите или зарегистрируйтесь.',
title: 'Личные сообщения недоступны',
text: 'Вы не авторизованы. Для личных сообщений сначала войдите в систему.',
});
return;
}
if (pageId === 'profile-view') {
openAuthRequiredModal({
title: 'Нужен аккаунт',
text: 'Чтобы открыть профиль, войдите или зарегистрируйтесь.',
title: 'Профиль недоступен',
text: 'Вы не авторизованы. Для профиля сначала войдите в систему.',
});
return;
}
if (pageId === 'notifications-view') {
openAuthRequiredModal({
title: 'Нужен аккаунт',
text: 'Чтобы получать уведомления, войдите или зарегистрируйтесь.',
title: 'Уведомления недоступны',
text: 'Вы не авторизованы. Для уведомлений сначала войдите в систему.',
});
return;
}
-3
View File
@@ -18,9 +18,6 @@ function createActionButton(action = {}, cleanupFns) {
button.append(action.iconNode);
} else if (action.icon) {
button.innerHTML = iconHtml(action.icon);
} else if (action.label === '⋮' || action.label === '⋯') {
// Меню экрана везде рисуется одной иконкой «⋯»
button.innerHTML = iconHtml('more');
} else {
button.textContent = String(action.label ?? '');
}
+5 -28
View File
@@ -1,32 +1,11 @@
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"/>',
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"/>',
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"/>',
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="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"/>',
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"/>',
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"/>',
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"/>',
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"/>',
@@ -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"/>',
'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"/>',
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"/>',
};
export function iconHtml(name, filled = false, className = '') {
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>`;
export function iconHtml(name, filled = false) {
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;">
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="once" checked />
<span>Использовать главный ключ только сейчас</span>
<span>Использовать blockchain key только сейчас</span>
</label>
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="save" />
<span>Сохранить главный ключ на этом устройстве</span>
<span>Сохранить blockchain key на этом устройстве</span>
</label>
</div>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, blockchain key попадёт в зашифрованный контейнер устройства.</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</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');
close({
password,
saveRoot: mode instanceof HTMLInputElement && mode.value === 'save',
saveBlockchain: mode instanceof HTMLInputElement && mode.value === 'save',
});
});
window.setTimeout(() => inputEl.focus(), 0);
@@ -482,48 +482,49 @@ export function render({navigate, chrome}) {
saved = null;
}
const savedRoot = String(saved?.rootKey || '').trim();
const savedBlockchain = String(saved?.blockchainKey || '').trim();
const savedClient = String(saved?.clientKey || '').trim();
if (savedRoot && savedClient) {
if (savedBlockchain && savedClient) {
return {
rootPrivatePkcs8B64: savedRoot,
blockchainPrivatePkcs8B64: savedBlockchain,
clientPrivatePkcs8B64: savedClient,
clientAddress: await clientAddressFromPrivatePkcs8(savedClient),
payerAddress: await clientAddressFromPrivatePkcs8(savedBlockchain),
};
}
const passwordResult = await passwordModal?.open({
title: 'Нужен пароль для обновления серверов доступа',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
note: savedClient
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.'
: 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен blockchain key.'
: 'На устройстве не хватает blockchain key и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
});
if (!passwordResult) {
throw new Error('Операция отменена пользователем.');
}
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);
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.');
const activeBlockchain = currentPda.forks?.at(-1)?.blockchainKey;
if (!activeBlockchain || !equalBytes(derivedBlockchainPublic, activeBlockchain)) {
throw new Error('Пароль не подходит: blockchain key не совпал с активным fork аккаунта.');
}
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
}
if (passwordResult.saveRoot) {
if (passwordResult.saveBlockchain) {
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
saveRoot: true,
saveBlockchain: false,
saveRoot: false,
saveBlockchain: true,
});
}
return {
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.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({
login: sessionLogin,
solanaEndpoint,
rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64,
blockchainPrivatePkcs8B64: signingMaterial.blockchainPrivatePkcs8B64,
clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64,
accessServers: normalizedList,
});
@@ -562,7 +563,7 @@ export function render({navigate, chrome}) {
refreshAddButton();
} catch (error) {
if (isInsufficientFundsForRentError(error)) {
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
showTopupRequiredStatus(target, signingMaterial?.payerAddress);
} else {
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>
<span class="settings-feature-toggle-hint">${hint}</span>
</span>
<input class="switch" id="${id}" type="checkbox" ${checked ? 'checked' : ''} />
<input class="settings-feature-switch" id="${id}" type="checkbox" ${checked ? 'checked' : ''} />
`;
return label;
}
@@ -34,7 +34,7 @@ export function render({ navigate, chrome }) {
const intro = document.createElement('div');
intro.className = 'settings-intro';
intro.innerHTML = `
<p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по заголовку «Настройки».</p>
<p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по логотипу в настройках.</p>
`;
const developerRow = createToggleRow({
+1 -1
View File
@@ -119,7 +119,7 @@ export function render({navigate, chrome}) {
back: { onClick: () => navigate('settings-view') },
actions: [
{
icon: 'plus',
label: '+',
title: 'Добавить файл',
ariaLabel: 'Добавить файл',
className: 'arweave-uploads-add',
+14 -37
View File
@@ -14,7 +14,7 @@ import {
formatSol,
getBalanceSol,
getSolanaWalletFromStoredSecret,
getWalletFromStoredClientKey,
getWalletFromStoredBlockchainKey,
getWalletFromStoredRootKey,
solanaAddressFromPublicKeyBase64,
transferSol,
@@ -138,9 +138,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
if (!root) return;
const recipientChoices = [
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
].filter(Boolean);
root.innerHTML = `
@@ -163,18 +161,15 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
<select class="select" id="channel-support-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
</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>
<select class="select" id="channel-support-sender-key">
<option value="client-key">Client key</option>
<option value="root-key">Root key</option>
</select>
<div class="channel-support-address" id="channel-support-sender-address">–</div>
<div class="channel-support-address" id="channel-support-sender-key">Blockchain key</div>
<div class="channel-support-address" id="channel-support-sender-address">—</div>
<div class="channel-support-balance-row">
<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>
</div>
@@ -247,31 +242,13 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
};
const resolveSenderWallet = async () => {
const keyId = String(senderSelect?.value || 'client-key');
const keyId = 'blockchain-key';
if (walletCache.has(keyId)) return walletCache.get(keyId);
const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
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 });
}
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
walletCache.set(keyId, wallet);
return wallet;
@@ -281,7 +258,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
if (busy || closed) return;
setBusy(true);
setStatus('Загрузка баланса…');
if (balanceEl) balanceEl.textContent = '–';
if (balanceEl) balanceEl.textContent = '—';
try {
const wallet = await resolveSenderWallet();
if (closed) return;
@@ -295,7 +272,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
setStatus('');
} catch (error) {
if (closed) return;
if (senderAddressEl) senderAddressEl.textContent = '–';
if (senderAddressEl) senderAddressEl.textContent = '—';
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
} finally {
if (!closed) setBusy(false);
@@ -304,8 +281,8 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
recipientSelect?.addEventListener('change', updateRecipientAddress);
senderSelect?.addEventListener('change', () => {
if (senderAddressEl) senderAddressEl.textContent = '–';
if (balanceEl) balanceEl.textContent = '–';
if (senderAddressEl) senderAddressEl.textContent = '—';
if (balanceEl) balanceEl.textContent = '—';
setStatus('');
void refreshBalance();
});
@@ -494,13 +471,13 @@ export function render({ navigate, route, chrome }) {
<div class="channel-about-hero">
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
<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 class="channel-about-tiles">
${cameFromChannel ? '' : `<button class="channel-about-tile" id="channel-about-open" type="button">${iconHtml('open')}<span>Открыть</span></button>`}
${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 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-link">${escapeHtml(shortLink)}</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>
</div>
`;
+11 -34
View File
@@ -6,7 +6,7 @@ import {
formatSol,
getBalanceSol,
getSolanaWalletFromStoredSecret,
getWalletFromStoredClientKey,
getWalletFromStoredBlockchainKey,
getWalletFromStoredRootKey,
solanaAddressFromPublicKeyBase64,
transferSol,
@@ -140,9 +140,7 @@ export function render({ navigate, route, chrome }) {
const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
const channelName = String(channel?.channelName || selector?.channelName || '').trim();
const recipientChoices = [
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
].filter(Boolean);
content.innerHTML = `
@@ -164,20 +162,17 @@ export function render({ navigate, route, chrome }) {
<select class="select" id="channel-donate-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
</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 class="channel-donate-section">
<label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label>
<select class="select" id="channel-donate-sender-key">
<option value="client-key">Client key</option>
<option value="root-key">Root key</option>
</select>
<div class="channel-donate-address" id="channel-donate-sender-address">–</div>
<div class="channel-donate-address" id="channel-donate-sender-key">Blockchain key</div>
<div class="channel-donate-address" id="channel-donate-sender-address">—</div>
<div class="channel-donate-balance-row">
<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>
</div>
</section>
@@ -233,31 +228,13 @@ export function render({ navigate, route, chrome }) {
};
const resolveSenderWallet = async () => {
const keyId = String(senderSelect?.value || 'client-key');
const keyId = 'blockchain-key';
if (walletCache.has(keyId)) return walletCache.get(keyId);
const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
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 });
}
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
walletCache.set(keyId, wallet);
return wallet;
@@ -267,7 +244,7 @@ export function render({ navigate, route, chrome }) {
if (busy || disposed) return;
setBusy(true);
setStatus('Загрузка баланса…');
if (balanceEl) balanceEl.textContent = '–';
if (balanceEl) balanceEl.textContent = '—';
try {
const wallet = await resolveSenderWallet();
if (disposed) return;
@@ -281,7 +258,7 @@ export function render({ navigate, route, chrome }) {
setStatus('');
} catch (error) {
if (disposed) return;
if (senderAddressEl) senderAddressEl.textContent = '–';
if (senderAddressEl) senderAddressEl.textContent = '—';
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
} finally {
if (!disposed) setBusy(false);
@@ -290,8 +267,8 @@ export function render({ navigate, route, chrome }) {
recipientSelect?.addEventListener('change', updateRecipientAddress);
senderSelect?.addEventListener('change', () => {
if (senderAddressEl) senderAddressEl.textContent = '–';
if (balanceEl) balanceEl.textContent = '–';
if (senderAddressEl) senderAddressEl.textContent = '—';
if (balanceEl) balanceEl.textContent = '—';
setStatus('');
void refreshBalance();
});
+67 -411
View File
@@ -9,7 +9,6 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import {
animatePress,
createSkeletonCard,
formatPostStamp,
formatRelativeTime,
longPressFeel,
shareOrCopyLink,
@@ -19,9 +18,6 @@ import {
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.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 {
composeMessageWithAttachments,
createAttachmentCarouselElement,
@@ -470,7 +466,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</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>Подпись</span><code>${escapeHtml(details.signature)}</code>
</div>
@@ -627,7 +623,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
const text = String(item?.text || '').trim() || 'удалено';
row.innerHTML = `
<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>
`;
list.append(row);
@@ -719,7 +715,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}
const timestamp = document.createElement('div');
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');
authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock);
@@ -965,176 +961,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
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) {
const wrap = document.createElement('div');
wrap.className = 'stack';
@@ -1199,124 +1025,6 @@ function renderSkeleton(screen) {
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 }) {
let selector = parseThreadSelector(route);
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
@@ -1334,31 +1042,19 @@ export function render({ navigate, route, chrome }) {
const threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button';
threadHeaderButton.className = 'topbar-identity channel-header-route-btn';
const threadHeaderText = document.createElement('span');
threadHeaderText.className = 'topbar-identity__text';
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
const threadHeaderTitle = document.createElement('span');
threadHeaderTitle.className = 'topbar-identity__title';
threadHeaderTitle.className = 'channel-header-title';
threadHeaderTitle.textContent = 'Обсуждение';
const threadHeaderChannel = document.createElement('span');
threadHeaderChannel.className = 'topbar-identity__sub';
threadHeaderChannel.className = 'channel-header-owner';
threadHeaderChannel.textContent = '…';
threadHeaderText.append(threadHeaderTitle, threadHeaderChannel);
threadHeaderButton.append(threadHeaderText);
threadHeaderButton.append(threadHeaderTitle, threadHeaderChannel);
threadHeaderButton.disabled = true;
// Меню обсуждения (V4): к записи, уведомления об ответах, поделиться записью
let threadMenuActions = null;
const header = createTopBar({
center: threadHeaderButton,
className: 'topbar--identity',
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');
chrome?.setTopbar(header);
@@ -1700,7 +1396,7 @@ export function render({ navigate, route, chrome }) {
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
if (threadHeaderButton) {
threadHeaderChannel.textContent = channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle;
threadHeaderChannel.textContent = `в канале «${channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle}»`;
threadHeaderButton.title = 'Открыть канал';
threadHeaderButton.disabled = false;
threadHeaderButton.onclick = (event) => {
@@ -1713,114 +1409,74 @@ export function render({ navigate, route, chrome }) {
}
clearContent();
const channelOwner = extractLoginFromBlockchainName(selector?.channel?.ownerBlockchainName || focus?.channelInfo?.ownerBlockchainName || '');
const openPost = () => {
const routeToChannel = buildChannelRouteFromThread(selector, resolvedChannelLabel);
if (routeToChannel) navigate(routeToChannel);
};
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 localSeq = 0;
const nextNumber = () => {
localSeq += 1;
return localSeq;
};
let ancestorsWrap = null;
if (ancestors.length) {
const ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'thread-block thread-block--ancestors';
ancestors.forEach((node) => ancestorsWrap.append(renderThreadContext(node, { onOpenPost: openPost })));
screen.append(ancestorsWrap);
}
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); }
ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
ancestors.forEach((node, index) => {
const heading = index === 0 ? resolveChannelHeadingFromNode(node) : '';
ancestorsWrap.append(renderNodeCard(node, heading, handlers, nextNumber()));
});
}
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 {
const empty = document.createElement('div');
empty.className = 'empty-note thread-empty';
empty.textContent = 'Пока нет комментариев. Можно начать разговор.';
listWrap.append(empty);
empty.className = 'empty-note';
empty.textContent = 'Пока нет ответов. Начните обсуждение.';
descendantsWrap.append(empty);
}
screen.append(listWrap, returnBtn);
let composerApi = null;
if (focus) {
composerApi = createThreadComposer({
draftKey: routeKey,
isAuthorized: state.session.isAuthorized,
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);
if (ancestorsWrap) {
screen.append(ancestorsWrap);
const divider = document.createElement('div');
divider.className = 'thread-history-divider';
screen.append(divider);
}
const focusWrap = screen.querySelector('.thread-block--focus');
if (focusWrap) screen.append(focusWrap);
screen.append(descendantsWrap);
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
const hasPendingScroll = pendingThreadScroll.has(routeKey);
+35 -61
View File
@@ -16,8 +16,8 @@ import {
animatePress,
createSkeletonCard,
dayKey,
formatClockTime,
formatDayLabel,
formatPostStamp,
formatRelativeTime,
longPressFeel,
shareOrCopyLink,
@@ -644,7 +644,7 @@ function openChannelMetaDetailsModal({
: 'Не установлен';
const changedAtLabel = changedAtMs
? new Date(Number(changedAtMs)).toLocaleString('ru-RU')
: '–';
: '—';
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="about-channel-modal">
@@ -658,7 +658,7 @@ function openChannelMetaDetailsModal({
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
<span>Владелец</span><strong>${escapeHtml(channel.ownerName || 'автор')}</strong>
<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(avatarState)}</span>
</div>
@@ -840,7 +840,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</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>Подпись</span><code>${escapeHtml(details.signature)}</code>
</div>
@@ -1079,7 +1079,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
item.className = 'ui-button entrypoint-history-item';
item.innerHTML = `
<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>
`;
item.addEventListener('click', () => {
@@ -1171,7 +1171,7 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
if (textEl) textEl.focus();
}
function openAddMessageModal({ channelName, channelTitle = '', onSubmit, navigate, isActive = () => true }) {
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
const typeWrap = document.createElement('label');
typeWrap.className = 'channel-editor__type';
typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>';
@@ -1188,8 +1188,7 @@ function openAddMessageModal({ channelName, channelTitle = '', onSubmit, navigat
id: 'channel-message-modal',
title: 'Новая запись',
submitLabel: 'Опубликовать',
placeholder: 'Чем хотите поделиться?',
destination: channelTitle ? `В канал «${channelTitle}»` : '',
placeholder: 'Напишите сообщение',
key: `channel-post:${channelName}`,
extraControl: typeWrap,
isActive,
@@ -1224,7 +1223,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
const text = String(item?.text || '').trim() || 'удалено';
row.innerHTML = `
<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>
`;
list.append(row);
@@ -1276,8 +1275,6 @@ function mapApiMessageToPost(message, selector, localNumber) {
primaryLikesCount: Number(message?.primaryLikesCount || 0),
shiningLikesCount: Number(message?.shiningLikesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
// Кто недавно ответил — для миниатюр у счётчика обсуждения. Сервер пока не отдаёт (см. docs/UI-Design).
recentRepliers: Array.isArray(message?.recentRepliers) ? message.recentRepliers.slice(0, 2) : [],
ratingsCount: Number(message?.ratingsCount || 0),
timestampMs: resolveMessageTimestampMs(message),
messageRef,
@@ -1510,7 +1507,6 @@ async function loadFromApi(route, channelId) {
avaSha256: String(payload.channel?.avaSha256 || '').trim(),
avaSize: Number(payload.channel?.avaSize || 0),
metaUpdatedAtMs: Number(payload.channel?.metaUpdatedAtMs || 0),
subscribersCount: Number(payload.channel?.subscribersCount || 0),
ownerName: ownerLogin || 'неизвестно',
},
posts,
@@ -1866,7 +1862,7 @@ function renderPostCard(post, {
const timestamp = document.createElement('div');
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 (versionsTotal > 1) {
const editedMarker = document.createElement('span');
@@ -2038,12 +2034,6 @@ function renderPostCard(post, {
<span class="channel-action-label">Обсуждение</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}`);
discussionButton.addEventListener('click', (event) => {
event.stopPropagation();
@@ -2077,6 +2067,8 @@ function renderPostCard(post, {
// Rating/opinion action is intentionally hidden from UI for now.
// Backend/subtype/counters remain supported so the feature can be restored later.
actions.append(likeButton, discussionButton);
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'ui-button channel-action-item channel-action-share';
@@ -2092,9 +2084,8 @@ function renderPostCard(post, {
await onShare(route);
});
// Порядок как в макете V4: лайк → поделиться → обсуждение; дата и «⋯» — справа
actions.append(likeButton, shareButton, discussionButton);
menuItems.unshift({ label: 'Ответить', iconHtml: iconHtml('reply'), action: () => replyButton.click() });
actions.append(shareButton);
menuItems.unshift({ label: 'Ответить', action: () => replyButton.click() });
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
const originalBtn = document.createElement('button');
originalBtn.type = 'button';
@@ -2136,7 +2127,7 @@ function renderPostCard(post, {
msgSubType: post.msgSubType,
}), { isActive });
});
menuItems.push({ label: 'Данные публикации', iconHtml: iconHtml('info'), action: () => detailsButton.click() });
menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() });
if (post.isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
@@ -2158,9 +2149,8 @@ function renderPostCard(post, {
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
});
});
menuItems.unshift({ label: 'Редактировать', iconHtml: iconHtml('edit'), action: () => editButton.click() });
menuItems.push({ divider: true });
menuItems.push({ label: 'Удалить', danger: true, iconHtml: iconHtml('trash'), action: async () => {
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
if (!await confirmDialog({
title: 'Удалить сообщение?',
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
@@ -2174,7 +2164,7 @@ function renderPostCard(post, {
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
attachMessageMenu(card, headRow, menuItems);
if (isOwnerPost) {
if (post.timestampMs) timestamp.textContent = formatPostStamp(post.timestampMs);
if (post.timestampMs) timestamp.textContent = formatClockTime(post.timestampMs);
const meta = document.createElement('div');
meta.className = 'channel-message-meta';
const moreButton = headRow.querySelector('.channel-message-more');
@@ -2203,12 +2193,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const actionButton = document.createElement('button');
actionButton.type = 'button';
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');
addMessageButton.type = 'button';
addMessageButton.className = 'channel-new-post';
addMessageButton.innerHTML = `${iconHtml('compose')}<span>Новая запись</span>`;
addMessageButton.className = 'channel-compose-bar';
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) => {
animatePress(event.currentTarget);
handlers.onAddMessage();
@@ -2245,8 +2236,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const itemDayKey = dayKey(item.timestampMs);
const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey;
if (startsNewDay) lastDayKey = itemDayKey;
// В ленте канала дата стоит у каждой записи; плашки дней остаются только в дневнике
if (startsNewDay && item.type !== 'meta' && channelData.isDiary) {
if (startsNewDay && item.type !== 'meta') {
const separator = document.createElement('div');
separator.className = 'channel-day-separator';
separator.innerHTML = `<span>${escapeHtml(formatDayLabel(item.timestampMs))}</span>`;
@@ -2310,11 +2300,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
composer.append(addMessageButton);
handlers.chrome?.setComposer(composer);
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
screen.append(feed);
const composer = document.createElement('div');
composer.className = 'channel-composer';
composer.append(actionButton);
handlers.chrome?.setComposer(composer);
screen.append(actionButton, feed);
} else {
screen.append(feed);
}
@@ -2426,18 +2412,17 @@ export function render({ navigate, route, chrome }) {
let activeChannelData = null;
let activeOpenEntrypointHistory = null;
let activeAddMessage = null;
const channelHeaderButton = document.createElement('button');
channelHeaderButton.type = 'button';
channelHeaderButton.className = 'topbar-identity channel-header-route-btn';
channelHeaderButton.innerHTML = '<span class="topbar-identity__text"><span class="topbar-identity__title">Канал</span><span class="topbar-identity__sub">Загрузка…</span></span>';
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
channelHeaderButton.innerHTML = '<span class="channel-header-title">Канал</span><span class="channel-header-owner">Загрузка…</span>';
channelHeaderButton.disabled = true;
const header = createTopBar({
center: channelHeaderButton,
back: { onClick: () => navigate('channels-list') },
className: 'channel-view-topbar topbar--identity',
className: 'channel-view-topbar',
actions: [
{
label: '⋮',
@@ -2457,20 +2442,16 @@ export function render({ navigate, route, chrome }) {
const aboutRoute = makeShineChannelAboutRoute(routeArgs);
const donateRoute = makeShineChannelDonateRoute(routeArgs);
const items = [];
items.push({ label: 'О канале', iconHtml: iconHtml('info'), action: () => { if (aboutRoute) navigate(aboutRoute); } });
if (apiData?.isOwnChannel && !apiData?.isDiary) {
items.push({ label: 'Новая запись', iconHtml: iconHtml('compose'), action: () => activeAddMessage?.() });
}
items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
items.push({
label: 'Оглавление',
iconHtml: iconHtml('list'),
action: () => {
if (activeOpenEntrypointHistory) activeOpenEntrypointHistory();
else showToast('В этом канале пока нет оглавления');
},
});
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?.isSubscribed) {
@@ -2887,21 +2868,15 @@ export function render({ navigate, route, chrome }) {
};
activeOpenEntrypointHistory = openEntrypointHistory;
if (channelHeaderButton) {
// Короткий адрес канала и число подписчиков; владелец и полный путь — в «О канале»
const slug = String(apiData?.channel?.name || '').trim();
const subs = Number(apiData?.channel?.subscribersCount || 0);
const ownerLabel = String(apiData?.channel?.ownerName || '').trim();
channelHeaderButton.replaceChildren();
const text = document.createElement('span');
text.className = 'topbar-identity__text';
const titleNode = document.createElement('span');
titleNode.className = 'topbar-identity__title';
titleNode.className = 'channel-header-title';
titleNode.textContent = titleLabel;
const subNode = document.createElement('span');
subNode.className = 'topbar-identity__sub';
subNode.textContent = apiData?.isDiary ? 'Только вам' : [slug ? `@${slug}` : '', subs ? String(subs) : ''].filter(Boolean).join(' · ');
text.append(titleNode, subNode);
channelHeaderButton.append(createChannelAvatarElement(apiData?.channel, 40), text);
channelHeaderButton.setAttribute('aria-label', `${titleLabel}. О канале`);
const ownerNode = document.createElement('span');
ownerNode.className = 'channel-header-owner';
ownerNode.textContent = ownerLabel ? `@${ownerLabel}` : 'О канале';
channelHeaderButton.append(titleNode, ownerNode);
channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget);
@@ -2935,10 +2910,9 @@ export function render({ navigate, route, chrome }) {
restorePosition,
showStatus,
isActive: () => !disposed,
onAddMessage: activeAddMessage = () => {
onAddMessage: () => {
openAddMessageModal({
channelName: apiData?.channel?.name || '',
channelTitle: apiData?.channel?.displayTitle || '',
navigate,
isActive: () => !disposed,
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>
</div>
<div class="channel-row-controls">
<span class="channel-row-time">–</span>
<span class="channel-row-time">—</span>
</div>
`;
row.prepend(createChannelAvatar(channel));
@@ -1163,7 +1163,7 @@ export function render({ navigate, route, chrome }) {
className: 'topbar--root',
actions: [
{
icon: 'plus',
label: '+',
title: 'Создать канал',
ariaLabel: 'Создать канал',
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 { createDropdownMenu } from '../components/dropdown-menu.js';
import { renderUserAvatar } from '../components/avatar-image.js';
@@ -99,18 +98,18 @@ function createChatHeaderParts(login, navigate) {
const identityButton = document.createElement('button');
identityButton.type = 'button';
identityButton.className = 'topbar-identity chat-header-peer-btn';
identityButton.className = 'chat-header-peer-btn';
identityButton.title = `Меню ${cleanLogin}`;
identityButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
const avatarSlot = document.createElement('span');
avatarSlot.className = 'chat-header-avatar-slot';
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');
nameEl.className = 'topbar-identity__title chat-header-peer-name';
nameEl.className = 'chat-header-peer-name';
const metaEl = document.createElement('span');
metaEl.className = 'topbar-identity__sub chat-header-peer-meta';
metaEl.className = 'chat-header-peer-meta';
textWrap.append(nameEl, metaEl);
identityButton.append(avatarSlot, textWrap);
@@ -120,9 +119,7 @@ function createChatHeaderParts(login, navigate) {
const fullName = [firstName, lastName].filter(Boolean).join(' ');
nameEl.textContent = fullName || cleanLogin;
const relation = chatRelationLabel(currentPeer?.relationType);
// Статус аккаунта, а не «в сети»: присутствие сервер не сообщает, выдумывать его нельзя
const shining = String(currentPeer?.shineStatus || '').toLowerCase() === 'shining' ? '✧ сияющий аккаунт' : '';
metaEl.textContent = [fullName ? `@${cleanLogin}` : '', shining, relation].filter(Boolean).join(' · ');
metaEl.textContent = [fullName ? `@${cleanLogin}` : '', relation].filter(Boolean).join(' · ');
metaEl.hidden = !metaEl.textContent;
const avatar = renderUserAvatar({
login: cleanLogin,
@@ -771,14 +768,6 @@ function resolveDeliveryStatus(messages, msg) {
return '…';
}
// Статус сообщения — иконкой и словом для экранного чтения (цвет не единственный носитель смысла)
const DELIVERY_STATUS_VIEW = {
'✓✓': { icon: 'check-double', label: 'Прочитано' },
'✓': { icon: 'check', label: 'Отправлено' },
'!': { icon: 'alert', label: 'Не отправлено' },
'…': { icon: 'clock', label: 'Отправляется' },
};
function resolveDeliveryTone(messages, msg) {
if (resolveEffectiveReadState(messages, msg).isRead) return 'read';
const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase();
@@ -1210,11 +1199,7 @@ function renderLog(
if (status) {
const statusNode = document.createElement('span');
statusNode.className = `bubble-status bubble-status--${resolveDeliveryTone(messages, msg)}`;
const statusView = DELIVERY_STATUS_VIEW[status] || DELIVERY_STATUS_VIEW['…'];
statusNode.innerHTML = iconHtml(statusView.icon);
statusNode.setAttribute('role', 'img');
statusNode.setAttribute('aria-label', statusView.label);
statusNode.title = statusView.label;
statusNode.textContent = status;
metaNode.append(statusNode);
}
@@ -1444,7 +1429,6 @@ export function render({ navigate, route, chrome }) {
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
const chatHeader = createTopBar({
center: chatHeaderParts.center,
className: 'topbar--identity',
back: { label: '←', onClick: () => navigate('messages-list') },
actions: [
{
+3 -3
View File
@@ -63,9 +63,9 @@ export function render({navigate, chrome}) {
<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">blockchain key – можно передать или нет</p>
<p class="meta-muted">root key – только если существует</p>
<p class="meta-muted">если ключа нет — он недоступен</p>
<p class="meta-muted">blockchain key — можно передать или нет</p>
<p class="meta-muted">root key — только если существует</p>
<p class="meta-muted">client key передаётся всегда</p>
<p class="meta-muted">подключение происходит напрямую через QR</p>
<p class="meta-muted">сервер не используется</p>
+3 -3
View File
@@ -209,9 +209,9 @@ function showClientUpdateHelp() {
'Если UI не обновился:\n\n'
+ '1) Закройте вкладки с Сиянием.\n'
+ `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n`
+ '3) Если приложение установлено как PWA – удалите его с устройства.\n'
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n'
+ `4) Откройте ${defaultServerHttp} заново и выполните вход.\n`
+ '5) Если всё ещё старая версия – откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
+ '5) Если всё ещё старая версия — откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
);
}
@@ -315,7 +315,7 @@ export function render({navigate, chrome}) {
};
const syncPwaButtonLabel = () => {
if (isStandalonePwaMode()) {
setPwaLabel('Приложение установлено – проверить уведомления');
setPwaLabel('Приложение установлено — проверить уведомления');
return;
}
if (canInstallPwa()) {
+14 -4
View File
@@ -1,5 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { iconHtml } from '../components/ui-icon.js';
import {
authService,
authorizeSession,
@@ -59,7 +58,7 @@ function requestCardHtml(request) {
const shortCode = formatPairingShortCode(request?.shortCode || '');
const client = String(request?.requesterClientPlatform || 'unknown');
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 sessionKind = pairingSessionKindLabel(requesterSessionType);
return `
@@ -126,8 +125,19 @@ async function runPairingOpWithSessionRestore(runAction) {
function makePasswordToggleIcons() {
return {
eye: iconHtml('eye', false, 'key-toggle-icon'),
eyeOff: iconHtml('eyeOff', false, 'key-toggle-icon'),
eye: `
<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 };
const SERVER_FIELDS = [
{ key: 'solanaServer', label: 'Сервер Solana' },
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
{ key: 'shineServerLogin', label: 'Сервер Сияния' },
{ key: 'arweaveServer', label: 'Сервер Arweave' },
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
];
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack entry-settings';
screen.className = 'stack';
const draft = {
language: state.entrySettings.language,
@@ -30,79 +30,62 @@ export function render({ navigate }) {
const timers = new Map();
let disposed = false;
const checks = [];
const body = document.createElement('div');
body.className = 'entry-servers';
body.className = 'card stack';
SERVER_FIELDS.forEach((field) => {
const block = document.createElement('div');
block.className = 'entry-server';
const head = document.createElement('div');
head.className = 'entry-server__head';
block.className = 'stack';
const title = document.createElement('label');
title.className = 'field-label';
title.htmlFor = `entry-${field.key}`;
title.textContent = field.label;
// Статус — он же кнопка повторной проверки.
const checkButton = document.createElement('button');
checkButton.className = 'text-btn entry-status';
checkButton.type = 'button';
const input = document.createElement('input');
input.className = 'input';
input.id = `entry-${field.key}`;
input.type = 'text';
input.autocapitalize = 'off';
input.spellcheck = false;
input.value = draft[field.key];
const hint = document.createElement('p');
hint.className = 'entry-server__hint';
const controls = document.createElement('div');
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 = '') => {
if (disposed) return;
if (value !== 'checking') draft.statuses[field.key] = value;
checkButton.classList.remove('is-available', 'is-unavailable', 'is-checking');
checkButton.disabled = false;
draft.statuses[field.key] = value;
checkButton.classList.remove('is-available', 'is-unavailable');
status.classList.remove('is-available', 'is-unavailable');
if (value === 'available') {
checkButton.textContent = 'Доступен';
status.textContent = field.key === 'shineServerLogin' && exactAddress
? `Доступен: ${exactAddress}`
: 'Доступен';
checkButton.classList.add('is-available');
status.classList.add('is-available');
} else if (value === 'unavailable') {
checkButton.textContent = 'Недоступен';
status.textContent = 'Недоступен';
checkButton.classList.add('is-unavailable');
} else if (value === 'checking') {
checkButton.textContent = 'Проверка…';
checkButton.classList.add('is-checking');
checkButton.disabled = true;
status.classList.add('is-unavailable');
} else {
checkButton.textContent = 'Проверить';
}
checkButton.setAttribute('aria-label', `${field.label}: ${checkButton.textContent}. Проверить ещё раз`);
if (field.key === 'shineServerLogin') {
const address = exactAddress || draft.shineServerHttp;
hint.textContent = address || '';
hint.hidden = !address;
status.textContent = field.key === 'shineServerLogin' && draft.shineServerHttp
? `Текущий адрес: ${draft.shineServerHttp}`
: 'Статус не проверен';
}
};
let checkSeq = 0;
const runCheck = async () => {
draft[field.key] = input.value.trim();
window.clearTimeout(timers.get(field.key));
// Результат устаревшей проверки (адрес успели поменять) не должен перезаписать новый.
const seq = ++checkSeq;
applyStatus('checking');
checkButton.disabled = true;
checkButton.textContent = 'Проверка...';
try {
if (field.key === 'shineServerLogin') {
const resolved = await resolveAndCheckShineServerLogin(input.value, draft.solanaServer);
if (seq !== checkSeq) return;
draft.shineServerHttp = resolved.httpBase;
draft.shineServer = resolved.wsUrl;
applyStatus(resolved.status, resolved.httpBase);
@@ -110,29 +93,26 @@ export function render({ navigate }) {
const next = await checkServerAvailabilityByKey(field.key, input.value, {
solanaEndpoint: draft.solanaServer,
});
if (seq !== checkSeq) return;
applyStatus(next);
}
} catch {
if (seq === checkSeq) applyStatus('unavailable');
} finally {
checkButton.disabled = false;
checkButton.textContent = 'Проверить';
}
};
applyStatus(draft.statuses[field.key]);
hint.hidden = field.key !== 'shineServerLogin' || !hint.textContent;
checks.push(runCheck);
checkButton.addEventListener('click', runCheck);
input.addEventListener('input', () => {
draft[field.key] = input.value;
checkSeq += 1;
applyStatus('idle');
window.clearTimeout(timers.get(field.key));
timers.set(field.key, window.setTimeout(() => {
void runCheck();
}, 3000));
});
input.addEventListener('change', () => {
input.addEventListener('blur', () => {
void runCheck();
});
input.addEventListener('keydown', (event) => {
@@ -142,17 +122,17 @@ export function render({ navigate }) {
}
});
head.append(title, checkButton);
block.append(head, input, hint);
controls.append(checkButton, status);
block.append(title, input, controls);
body.append(block);
});
const actions = document.createElement('div');
actions.className = 'entry-settings__actions';
actions.className = 'auth-footer-actions';
if (isLocalDemoAvailable()) {
const localDemoButton = document.createElement('button');
localDemoButton.className = 'secondary-btn';
localDemoButton.className = 'secondary-btn preauth-local-demo-btn';
localDemoButton.type = 'button';
localDemoButton.textContent = 'Открыть локальный тестовый режим';
localDemoButton.addEventListener('click', () => {
@@ -165,12 +145,18 @@ export function render({ navigate }) {
const serverUiButton = document.createElement('button');
serverUiButton.className = 'secondary-btn';
serverUiButton.type = 'button';
serverUiButton.textContent = 'Настроить свой сервер ↗';
serverUiButton.textContent = 'Настроить свой сервер';
serverUiButton.addEventListener('click', () => {
const url = new URL('server-ui.html', window.location.href);
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');
saveButton.className = 'primary-btn';
saveButton.type = 'button';
@@ -184,8 +170,7 @@ export function render({ navigate }) {
}
});
actions.prepend(saveButton);
actions.append(serverUiButton);
actions.append(serverUiButton, cancelButton, saveButton);
screen.append(
createTopBar({
@@ -197,12 +182,8 @@ export function render({ navigate }) {
);
screen.cleanup = () => {
disposed = true;
timers.forEach((timerId) => window.clearTimeout(timerId));
};
// Серверы проверяются сразу — зелёный «Доступен» видно без лишних нажатий.
checks.forEach((check) => { void check(); });
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