GPT: сгенерено и не проверено (смена ключей пользователя)

This commit is contained in:
AidarKC
2026-09-27 15:50:27 +03:00
parent 13693a0a53
commit 151a2c1754
94 changed files with 5327 additions and 815 deletions
@@ -3,7 +3,9 @@ package server.archive;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import shine.db.dao.BlocksDAO; import shine.db.dao.BlocksDAO;
import shine.db.dao.KeyRotationCandidateBlocksDAO;
import shine.db.entities.BlockEntry; import shine.db.entities.BlockEntry;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -14,6 +16,7 @@ public final class ArweaveBlockPublisherService {
private final ArweaveBlocksConfig cfg; private final ArweaveBlocksConfig cfg;
private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
private final KeyRotationCandidateBlocksDAO candidateBlocksDAO = KeyRotationCandidateBlocksDAO.getInstance();
private final ArweaveL1Uploader arweaveUploader; private final ArweaveL1Uploader arweaveUploader;
private final TurboDataItemUploader turboUploader; private final TurboDataItemUploader turboUploader;
@@ -25,13 +28,57 @@ public final class ArweaveBlockPublisherService {
public int runCycle() throws Exception { public int runCycle() throws Exception {
if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0; if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0;
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
if (candidates.isEmpty()) return 0; int maxItems = cfg.publishMaxItems();
return switch (cfg.publishMode()) { int attempted = 0;
int published = 0;
// Ротация приоритетна: пользователь уже заблокирован для обычной записи и ждёт завершения копирования.
List<KeyRotationCandidateBlockEntry> rotationCandidates = candidateBlocksDAO.listPendingArweave(maxItems);
if (!rotationCandidates.isEmpty()) {
attempted += rotationCandidates.size();
published += switch (cfg.publishMode()) {
case TURBO -> publishTurboRotation(rotationCandidates);
case ARWEAVE -> publishDirectArweaveRotation(rotationCandidates);
case NONE -> 0;
};
}
int remaining = Math.max(0, maxItems - attempted);
if (remaining == 0) return published;
List<BlockEntry> candidates = blocksDAO.listPendingArweave(remaining);
if (candidates.isEmpty()) return published;
published += switch (cfg.publishMode()) {
case TURBO -> publishTurbo(candidates); case TURBO -> publishTurbo(candidates);
case ARWEAVE -> publishDirectArweave(candidates); case ARWEAVE -> publishDirectArweave(candidates);
case NONE -> 0; case NONE -> 0;
}; };
return published;
}
private int publishTurboRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
int published = 0;
Exception firstFailure = null;
for (KeyRotationCandidateBlockEntry e : candidates) {
byte[] raw = e.getBlockBytes();
byte[] id = e.getDataItemId();
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
try {
TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id);
candidateBlocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis());
published++;
log.debug("Turbo published key-rotation DataItem {} chain={} block={}",
result.dataItemId(), e.getCandidateBlockchainName(), e.getBlockNumber());
} catch (Exception ex) {
if (firstFailure == null) firstFailure = ex;
log.warn("Turbo key-rotation publish failed: chain={} block={} bytes={} error={}",
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length, ex.getMessage());
}
}
if (published > 0) log.info("Published {} SHiNE key-rotation DataItems through Turbo", published);
if (published == 0 && firstFailure != null) throw firstFailure;
return published;
} }
private int publishTurbo(List<BlockEntry> candidates) throws Exception { private int publishTurbo(List<BlockEntry> candidates) throws Exception {
@@ -57,6 +104,40 @@ public final class ArweaveBlockPublisherService {
return published; return published;
} }
private int publishDirectArweaveRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
List<byte[]> items = new ArrayList<>();
List<byte[]> ids = new ArrayList<>();
long estimated = 32;
for (KeyRotationCandidateBlockEntry e : candidates) {
byte[] raw = e.getBlockBytes();
byte[] id = e.getDataItemId();
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
long next = estimated + 64L + raw.length;
if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break;
if (next > cfg.publishMaxBundleBytes()) {
log.error("One key-rotation DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}",
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length);
continue;
}
items.add(raw);
ids.add(id);
estimated = next;
}
if (items.isEmpty()) return 0;
byte[] bundle = Ans104Bundle.write(items);
List<ArweaveL1Uploader.Tag> rootTags = List.of(
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0")
);
ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags);
candidateBlocksDAO.markArweavePublished(ids, System.currentTimeMillis());
log.info("Published {} SHiNE key-rotation DataItems in direct Arweave root tx {} (bundle={} bytes)",
items.size(), result.txId(), bundle.length);
return items.size();
}
private int publishDirectArweave(List<BlockEntry> candidates) throws Exception { private int publishDirectArweave(List<BlockEntry> candidates) throws Exception {
List<byte[]> items = new ArrayList<>(); List<byte[]> items = new ArrayList<>();
List<byte[]> ids = new ArrayList<>(); List<byte[]> ids = new ArrayList<>();
@@ -25,6 +25,10 @@ public final class BodyRecordParser {
&& (v == (CreateChannelBody.VER & 0xFFFF))) { && (v == (CreateChannelBody.VER & 0xFFFF))) {
return new CreateChannelBody(subType, version, bodyBytes).check(); return new CreateChannelBody(subType, version, bodyBytes).check();
} }
if (st == (ForkBody.SUBTYPE & 0xFFFF)
&& (v == (ForkBody.VER & 0xFFFF))) {
return new ForkBody(subType, version, bodyBytes).check();
}
throw new IllegalArgumentException( throw new IllegalArgumentException(
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st) String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
); );
@@ -35,6 +35,8 @@ public final class MsgSubType {
/** HeaderBody: subType всегда 0 (compat). */ /** HeaderBody: subType всегда 0 (compat). */
public static final short HEADER_COMPAT = 0; public static final short HEADER_COMPAT = 0;
public static final short TECH_CREATE_CHANNEL = 1; public static final short TECH_CREATE_CHANNEL = 1;
/** Новый fork/ротация ключей: ссылка на родительскую цепочку и точку отката. */
public static final short TECH_FORK = 2;
/* ===================== TEXT (msg_type=1) ===================== */ /* ===================== TEXT (msg_type=1) ===================== */
@@ -53,7 +55,7 @@ public final class MsgSubType {
/** /**
* REPLY — ответ на сообщение. * REPLY — ответ на сообщение.
* НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32). * НЕ в линии. Имеет target (toLogin + blockNumber + hash32).
* Может указывать на чужой блокчейн/чужую линию/чужой канал. * Может указывать на чужой блокчейн/чужую линию/чужой канал.
*/ */
public static final short TEXT_REPLY = 20; public static final short TEXT_REPLY = 20;
@@ -69,7 +71,7 @@ public final class MsgSubType {
/** /**
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала. * REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария. * Имеет hasLine + target (toLogin + toBlockGlobalNumber + toBlockHash32) + текст комментария.
*/ */
public static final short TEXT_REPOST = 50; public static final short TEXT_REPOST = 50;
@@ -1,29 +1,30 @@
package blockchain.body; package blockchain.body;
import utils.blockchain.BlockchainNameUtil;
/** /**
* BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля). * BodyHasTarget — дополнительный интерфейс для body, которые ссылаются на логическую цель.
* *
* Новое правило: * Актуальное правило target:
* - toLogin НЕ храним в байтах блока. * - в подписываемых байтах хранится login цели;
* - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN". * - номер fork/blockchainName в target не хранится;
* - идентичность цели задаётся как login + blockNumber + blockHash.
* *
* Все методы могут возвращать null. * Это позволяет одной и той же логической записи сохранять ссылки после fork,
* если её номер и SHA-256 hash остались прежними.
*/ */
public interface BodyHasTarget { public interface BodyHasTarget {
/** login цели (nullable). Вычисляется из toBchName(). */ /** login цели. Актуальные runtime-body обязаны переопределять этот метод. */
default String toLogin() { default String toLogin() { return null; }
String bch = toBchName();
if (bch == null) return null;
return BlockchainNameUtil.loginFromBlockchainName(bch);
}
/** blockchainName цели (nullable). */ /**
String toBchName(); * Legacy source-level accessor. В новом подписанном target blockchainName не хранится.
* Оставлен временно только чтобы старые вспомогательные классы компилировались;
* runtime-логика не должна использовать его для новых блоков.
*/
@Deprecated
default String toBchName() { return null; }
/** globalNumber цели (nullable). */ /** globalNumber цели (nullable только если конкретный subtype не содержит target). */
Integer toBlockGlobalNumber(); Integer toBlockGlobalNumber();
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */ /** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
@@ -1,7 +1,6 @@
package blockchain.body; package blockchain.body;
import blockchain.MsgSubType; import blockchain.MsgSubType;
import utils.blockchain.BlockchainNameUtil;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.ByteOrder; import java.nio.ByteOrder;
@@ -12,96 +11,38 @@ import java.util.Objects;
/** /**
* ConnectionBody — type=3, ver=1 (в заголовке блока). * ConnectionBody — type=3, ver=1 (в заголовке блока).
* *
* subType (в заголовке блока) как MsgSubType: * bodyBytes (BigEndian):
* FRIEND=10, UNFRIEND=11
* CONTACT=20, UNCONTACT=21
* FOLLOW=30, UNFOLLOW=31
* SPOUSE=40, UNSPOUSE=41
* PARENT=50, UNPARENT=51
* CHILD=52, UNCHILD=53
* SIBLING=54, UNSIBLING=55
* FRIEND=14, UNFRIEND=15
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
*
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
* [4] lineCode * [4] lineCode
* [4] prevLineNumber * [4] prevLineNumber
* [32] prevLineHash32 * [32] prevLineHash32
* [4] thisLineNumber * [4] thisLineNumber
* * [1] toLoginLen (uint8)
* [1] toBlockchainNameLen (uint8) * [N] toLogin UTF-8
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber (int32) * [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes) * [32] toBlockHash32 (raw 32 bytes)
* *
* toLogin вычисляется автоматически из toBlockchainName: * Номер fork/blockchainName в target не хранится.
* toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName)
*/ */
/**
* =========================================================================
* ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов)
* =========================================================================
*
* Термины:
* - ROOT линии/канала = блок, который "начинает" линию:
* * для канала "0" root = HEADER (blockNumber=0)
* * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока)
*
* 1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*):
* FRIEND / CONTACT -> цель ВСЕГДА HEADER пользователя:
* toBlockNumber = 0
* toBlockHash32 = hash32(HEADER цели)
*
* 2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE):
* FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER):
* toBlockNumber = 0
* toBlockHash32 = hash32(HEADER цели)
*
* FOLLOW/подписка на конкретный канал пользователя ->
* цель = ROOT этого канала:
* - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER)
* - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL),
* toBlockHash32=hash32(CREATE_CHANNEL)
*
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
*
* Зачем так:
* - связи и подписки всегда стабильны и не ломаются при новых постах,
* - один понятный инвариант: "подписка всегда указывает на root линии".
* =========================================================================
*/
public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine { public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
public static final short TYPE = 3; public static final short TYPE = 3;
public static final short VER = 1; public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header public final short subType;
public final short version; // из header public final short version;
// line
public final int lineCode; public final int lineCode;
public final int prevLineNumber; public final int prevLineNumber;
public final byte[] prevLineHash32; public final byte[] prevLineHash32;
public final int thisLineNumber; public final int thisLineNumber;
// payload public final String toLogin;
public final String toBlockchainName;
public final int toBlockGlobalNumber; public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; public final byte[] toBlockHash32;
public ConnectionBody(short subType, short version, byte[] bodyBytes) { public ConnectionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null"); Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType; this.subType = subType;
this.version = version; this.version = version;
@@ -111,34 +52,25 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
if (!isValidSubType(this.subType)) { if (!isValidSubType(this.subType)) {
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF)); throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
} }
if (bodyBytes.length < 4 + 4 + 32 + 4 + 1 + 1 + 4 + 32) {
// минимум:
// lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32]
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) {
throw new IllegalArgumentException("ConnectionBody too short"); throw new IllegalArgumentException("ConnectionBody too short");
} }
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.lineCode = bb.getInt(); this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt(); this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32]; this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32); bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt(); this.thisLineNumber = bb.getInt();
int bchLen = Byte.toUnsignedInt(bb.get()); int loginLen = Byte.toUnsignedInt(bb.get());
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short"); if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
byte[] bchBytes = new byte[bchLen];
bb.get(bchBytes);
this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8);
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt(); this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32]; this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32); bb.get(this.toBlockHash32);
@@ -150,39 +82,68 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
byte[] prevLineHash32, byte[] prevLineHash32,
int thisLineNumber, int thisLineNumber,
short subType, short subType,
String toBlockchainName, String toLogin,
int toBlockGlobalNumber, int toBlockGlobalNumber,
byte[] toBlockHash32) { byte[] toBlockHash32) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF)); if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
// Железное правило формата: bchName -> login + "-NNN"
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) {
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.lineCode = lineCode; this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber; this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); this.prevLineHash32 = prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32);
this.thisLineNumber = thisLineNumber; this.thisLineNumber = thisLineNumber;
this.subType = subType; this.subType = subType;
this.version = VER; this.version = VER;
this.toLogin = toLogin;
this.toBlockchainName = toBlockchainName;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} }
@Override
public ConnectionBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
if (prevLineNumber == -1) {
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
} else if (prevLineHash32 == null || prevLineHash32.length != 32) {
throw new IllegalArgumentException("prevLineHash32 invalid");
}
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
return this;
}
@Override
public byte[] toBytes() {
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginBytes.length == 0 || loginBytes.length > 255)
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 != 32");
int cap = 4 + 4 + 32 + 4 + 1 + loginBytes.length + 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.put((byte) loginBytes.length);
bb.put(loginBytes);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
return bb.array();
}
private static boolean isValidSubType(short st) { private static boolean isValidSubType(short st) {
int v = st & 0xFFFF; int v = st & 0xFFFF;
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF) return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
@@ -211,76 +172,18 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF); || v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
} }
@Override
public ConnectionBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
// line rule (как было)
if (prevLineNumber == -1) {
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
} else {
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
}
if (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("toBlockchainName is blank");
// гарантируем вычислимый toLogin (иначе target “битый” по стандарту)
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null)
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
return this;
}
@Override
public byte[] toBytes() {
byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (bchBytes.length == 0 || bchBytes.length > 255)
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 != 32");
int cap = 4 + (4 + 32 + 4)
+ 1 + bchBytes.length
+ 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.put((byte) bchBytes.length);
bb.put(bchBytes);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
return bb.array();
}
private static boolean isAllZero32(byte[] b) { private static boolean isAllZero32(byte[] b) {
if (b == null || b.length != 32) return true; if (b == null || b.length != 32) return true;
for (int i = 0; i < 32; i++) if (b[i] != 0) return false; for (byte value : b) if (value != 0) return false;
return true; return true;
} }
/* ====================== BodyHasLine ====================== */
@Override public int lineCode() { return lineCode; } @Override public int lineCode() { return lineCode; }
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
@Override public int lineSeq() { return thisLineNumber; } @Override public int lineSeq() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */ @Override public String toLogin() { return toLogin; }
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
} }
@@ -0,0 +1,194 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* TECH_FORK body (type=0, subType=2, version=1).
*
* Записывается первым новым блоком после точной перепубликации выбранного
* префикса старой цепочки новым blockchain key.
*
* body bytes (BigEndian):
* [32] parentBlockchainKey
* [4] forkPointBlockNumber
* [32] forkPointBlockHash32
* [8] forkPointTimestampMs
* [4] parentTipBlockNumber
* [32] parentTipBlockHash32
* [8] parentTipTimestampMs
* [4] discardedBlocksCount
* [1] reasonCode
* [2] commentUtf8Length
* [N] comment UTF-8 (0..1024 bytes)
*/
public final class ForkBody implements BodyRecord {
public static final short TYPE = 0;
public static final short VER = 1;
public static final short SUBTYPE = MsgSubType.TECH_FORK;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public static final int REASON_ROUTINE_ROTATION = 1;
public static final int REASON_POSSIBLE_COMPROMISE = 2;
public static final int REASON_CONFIRMED_COMPROMISE_ROLLBACK = 3;
public static final int REASON_RECOVERY = 4;
public static final int MAX_COMMENT_UTF8_LEN = 1024;
private static final int FIXED_LEN = 32 + 4 + 32 + 8 + 4 + 32 + 8 + 4 + 1 + 2;
public final short subType;
public final short version;
public final byte[] parentBlockchainKey32;
public final int forkPointBlockNumber;
public final byte[] forkPointBlockHash32;
public final long forkPointTimestampMs;
public final int parentTipBlockNumber;
public final byte[] parentTipBlockHash32;
public final long parentTipTimestampMs;
public final int discardedBlocksCount;
public final int reasonCode;
public final String comment;
public ForkBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)");
}
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody version must be 1");
}
if (bodyBytes.length < FIXED_LEN) {
throw new IllegalArgumentException("ForkBody too short");
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.parentBlockchainKey32 = new byte[32];
bb.get(this.parentBlockchainKey32);
this.forkPointBlockNumber = bb.getInt();
this.forkPointBlockHash32 = new byte[32];
bb.get(this.forkPointBlockHash32);
this.forkPointTimestampMs = bb.getLong();
this.parentTipBlockNumber = bb.getInt();
this.parentTipBlockHash32 = new byte[32];
bb.get(this.parentTipBlockHash32);
this.parentTipTimestampMs = bb.getLong();
this.discardedBlocksCount = bb.getInt();
this.reasonCode = Byte.toUnsignedInt(bb.get());
int commentLen = Short.toUnsignedInt(bb.getShort());
if (commentLen > MAX_COMMENT_UTF8_LEN) {
throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024");
}
if (bb.remaining() != commentLen) {
throw new IllegalArgumentException("ForkBody tail mismatch: remaining=" + bb.remaining() + " commentLen=" + commentLen);
}
byte[] commentBytes = new byte[commentLen];
bb.get(commentBytes);
this.comment = new String(commentBytes, StandardCharsets.UTF_8);
}
public ForkBody(byte[] parentBlockchainKey32,
int forkPointBlockNumber,
byte[] forkPointBlockHash32,
long forkPointTimestampMs,
int parentTipBlockNumber,
byte[] parentTipBlockHash32,
long parentTipTimestampMs,
int discardedBlocksCount,
int reasonCode,
String comment) {
this.subType = SUBTYPE;
this.version = VER;
this.parentBlockchainKey32 = copy32(parentBlockchainKey32, "parentBlockchainKey32");
this.forkPointBlockNumber = forkPointBlockNumber;
this.forkPointBlockHash32 = copy32(forkPointBlockHash32, "forkPointBlockHash32");
this.forkPointTimestampMs = forkPointTimestampMs;
this.parentTipBlockNumber = parentTipBlockNumber;
this.parentTipBlockHash32 = copy32(parentTipBlockHash32, "parentTipBlockHash32");
this.parentTipTimestampMs = parentTipTimestampMs;
this.discardedBlocksCount = discardedBlocksCount;
this.reasonCode = reasonCode;
this.comment = normalizeComment(comment);
}
@Override
public ForkBody check() {
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)");
}
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody version must be 1");
}
require32(parentBlockchainKey32, "parentBlockchainKey32");
require32(forkPointBlockHash32, "forkPointBlockHash32");
require32(parentTipBlockHash32, "parentTipBlockHash32");
if (forkPointBlockNumber < 0) throw new IllegalArgumentException("forkPointBlockNumber < 0");
if (parentTipBlockNumber < forkPointBlockNumber) {
throw new IllegalArgumentException("parentTipBlockNumber < forkPointBlockNumber");
}
if (forkPointTimestampMs < 0) throw new IllegalArgumentException("forkPointTimestampMs < 0");
if (parentTipTimestampMs < 0) throw new IllegalArgumentException("parentTipTimestampMs < 0");
int expectedDiscarded = parentTipBlockNumber - forkPointBlockNumber;
if (discardedBlocksCount != expectedDiscarded) {
throw new IllegalArgumentException("discardedBlocksCount must equal parentTipBlockNumber - forkPointBlockNumber");
}
if (!isReasonSupported(reasonCode)) {
throw new IllegalArgumentException("Unsupported fork reasonCode=" + reasonCode);
}
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
if (commentUtf8.length > MAX_COMMENT_UTF8_LEN) {
throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024");
}
return this;
}
@Override
public byte[] toBytes() {
check();
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
ByteBuffer bb = ByteBuffer.allocate(FIXED_LEN + commentUtf8.length).order(ByteOrder.BIG_ENDIAN);
bb.put(parentBlockchainKey32);
bb.putInt(forkPointBlockNumber);
bb.put(forkPointBlockHash32);
bb.putLong(forkPointTimestampMs);
bb.putInt(parentTipBlockNumber);
bb.put(parentTipBlockHash32);
bb.putLong(parentTipTimestampMs);
bb.putInt(discardedBlocksCount);
bb.put((byte) reasonCode);
bb.putShort((short) commentUtf8.length);
bb.put(commentUtf8);
return bb.array();
}
public static boolean isReasonSupported(int code) {
return code == REASON_ROUTINE_ROTATION
|| code == REASON_POSSIBLE_COMPROMISE
|| code == REASON_CONFIRMED_COMPROMISE_ROLLBACK
|| code == REASON_RECOVERY;
}
private static byte[] copy32(byte[] value, String name) {
require32(value, name);
return Arrays.copyOf(value, 32);
}
private static void require32(byte[] value, String name) {
if (value == null || value.length != 32) {
throw new IllegalArgumentException(name + " must be 32 bytes");
}
}
private static String normalizeComment(String value) {
if (value == null) return "";
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
}
}
@@ -15,12 +15,13 @@ import java.util.Objects;
* 1 = LIKE * 1 = LIKE
* 2 = UNLIKE * 2 = UNLIKE
* *
* bodyBytes (BigEndian), новый формат: * bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8) * [1] toLoginLen (uint8)
* [N] toBlockchainName UTF-8 * [N] toLogin UTF-8
* [4] toBlockGlobalNumber (int32) * [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes) * [32] toBlockHash32 (raw 32 bytes)
* *
* Номер fork/blockchainName в target не хранится.
* ЛИНИИ НЕТ. * ЛИНИИ НЕТ.
*/ */
public final class ReactionBody implements BodyRecord, BodyHasTarget { public final class ReactionBody implements BodyRecord, BodyHasTarget {
@@ -30,10 +31,10 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header public final short subType;
public final short version; // из header public final short version;
public final String toBlockchainName; public final String toLogin;
public final int toBlockGlobalNumber; public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; public final byte[] toBlockHash32;
@@ -49,40 +50,37 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
if (!isSupportedSubType(this.subType)) { if (!isSupportedSubType(this.subType)) {
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF)); throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
} }
// минимум: nameLen[1]+name[1]+global[4]+hash[32]
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short"); if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
int nameLen = Byte.toUnsignedInt(bb.get()); int loginLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short"); if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
byte[] nameBytes = new byte[nameLen]; byte[] loginBytes = new byte[loginLen];
bb.get(nameBytes); bb.get(loginBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt(); this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32]; this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32); bb.get(this.toBlockHash32);
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
} }
public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) { public ReactionBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
this.subType = MsgSubType.REACTION_LIKE; this.subType = MsgSubType.REACTION_LIKE;
this.version = VER; this.version = VER;
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = toBlockchainName; this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} }
@@ -91,37 +89,30 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
public ReactionBody check() { public ReactionBody check() {
if (!isSupportedSubType(subType)) if (!isSupportedSubType(subType))
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF)); throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
if (toLogin == null || toLogin.isBlank())
if (toBlockchainName == null || toBlockchainName.isBlank()) throw new IllegalArgumentException("toLogin is blank");
throw new IllegalArgumentException("toBlockchainName is blank");
if (toBlockGlobalNumber < 0) if (toBlockGlobalNumber < 0)
throw new IllegalArgumentException("toBlockGlobalNumber < 0"); throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 invalid"); throw new IllegalArgumentException("toBlockHash32 invalid");
return this; return this;
} }
@Override @Override
public byte[] toBytes() { public byte[] toBytes() {
byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
if (nameBytes.length == 0 || nameBytes.length > 255) if (loginBytes.length == 0 || loginBytes.length > 255)
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
int cap = 1 + nameBytes.length + 4 + 32; ByteBuffer bb = ByteBuffer.allocate(1 + loginBytes.length + 4 + 32).order(ByteOrder.BIG_ENDIAN);
bb.put((byte) loginBytes.length);
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.put(loginBytes);
bb.put((byte) nameBytes.length);
bb.put(nameBytes);
bb.putInt(toBlockGlobalNumber); bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32); bb.put(toBlockHash32);
return bb.array(); return bb.array();
} }
/* ====================== BodyHasTarget ====================== */ @Override public String toLogin() { return toLogin; }
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -13,11 +13,11 @@ import java.util.Objects;
/** /**
* StatusActionBody — type=5, ver=1. * StatusActionBody — type=5, ver=1.
* *
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение. * Все STATUS_ACTION имеют target на конкретный логический блок и опциональный текст-пояснение.
* *
* Формат bodyBytes (BigEndian): * Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8) * [1] toLoginLen (uint8)
* [N] toBlockchainName UTF-8 * [N] toLogin UTF-8
* [4] toBlockGlobalNumber * [4] toBlockGlobalNumber
* [32] toBlockHash32 * [32] toBlockHash32
* [2] textLenBytes (uint16) * [2] textLenBytes (uint16)
@@ -31,7 +31,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
public final short subType; public final short subType;
public final short version; public final short version;
public final String toBlockchainName; public final String toLogin;
public final int toBlockGlobalNumber; public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; public final byte[] toBlockHash32;
public final String message; public final String message;
@@ -51,33 +51,32 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short"); ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
int nameLen = Byte.toUnsignedInt(bb.get()); int loginLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0"); if (loginLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toLoginLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short"); ensureMin(bb, loginLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
byte[] nameBytes = new byte[nameLen]; byte[] loginBytes = new byte[loginLen];
bb.get(nameBytes); bb.get(loginBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt(); this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32]; this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32); bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text"); this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
ensureNoTail(bb, "StatusActionBody"); ensureNoTail(bb, "StatusActionBody");
} }
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) { public StatusActionBody(short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null"); Objects.requireNonNull(message, "message == null");
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType"); if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.subType = subType; this.subType = subType;
this.version = VER; this.version = VER;
this.toBlockchainName = toBlockchainName; this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message; this.message = message;
@@ -85,16 +84,10 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
@Override @Override
public StatusActionBody check() { public StatusActionBody check() {
if (!isSupportedSubType(subType)) { if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF)); if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("STATUS_ACTION toLogin is blank");
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) { if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (message == null) throw new IllegalArgumentException("message is null"); if (message == null) throw new IllegalArgumentException("message is null");
return this; return this;
} }
@@ -104,15 +97,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) { if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255"); throw new IllegalArgumentException("STATUS_ACTION toLogin utf8 len must be 1..255");
}
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length) ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN); .order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length); bb.put((byte) loginUtf8.length);
bb.put(nameUtf8); bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber); bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32); bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length); bb.putShort((short) msgUtf8.length);
@@ -120,7 +112,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
return bb.array(); return bb.array();
} }
@Override public String toBchName() { return toBlockchainName; } @Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -141,14 +133,11 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
int len = Short.toUnsignedInt(bb.getShort()); int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) return ""; if (len == 0) return "";
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len]; byte[] bytes = new byte[len];
bb.get(bytes); bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder() var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT) .onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT); .onUnmappableCharacter(CodingErrorAction.REPORT);
try { try {
return decoder.decode(ByteBuffer.wrap(bytes)).toString(); return decoder.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException e) { } catch (CharacterCodingException e) {
@@ -33,23 +33,13 @@ import java.util.Objects;
* [2] textLenBytes (uint16) * [2] textLenBytes (uint16)
* [N] text UTF-8 * [N] text UTF-8
* *
* EDIT_POST: * EDIT_POST / REPOST:
* [4] lineCode * [4] lineCode
* [4] prevLineNumber * [4] prevLineNumber
* [32] prevLineHash32 * [32] prevLineHash32
* [4] thisLineNumber * [4] thisLineNumber
* [4] toBlockGlobalNumber (int32) * [1] toLoginLen (uint8)
* [32] toBlockHash32 * [N] toLogin UTF-8
* [2] textLenBytes (uint16)
* [N] text UTF-8
*
* REPOST:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber (int32) * [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 * [32] toBlockHash32
* [2] textLenBytes (uint16) * [2] textLenBytes (uint16)
@@ -72,7 +62,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
public final int thisLineNumber; public final int thisLineNumber;
// target (для EDIT_POST / REPOST) // target (для EDIT_POST / REPOST)
public final String toBlockchainName; // nullable для POST/EDIT_POST public final String toLogin; // nullable для сообщений без target
public final Integer toBlockGlobalNumber; // nullable для POST public final Integer toBlockGlobalNumber; // nullable для POST
public final byte[] toBlockHash32; // nullable для POST public final byte[] toBlockHash32; // nullable для POST
@@ -116,29 +106,19 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.thisLineNumber = bb.getInt(); this.thisLineNumber = bb.getInt();
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
// нужен target ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TEXT target missing");
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target"); int loginLen = Byte.toUnsignedInt(bb.get());
int tgtNum = bb.getInt(); if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
byte[] tgtHash = new byte[32]; ensureMin(bb, loginLen + 4 + 32 + 2, "TEXT target payload too short");
bb.get(tgtHash); byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toBlockchainName = null; this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = tgtNum;
this.toBlockHash32 = tgtHash;
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPOST missing target");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("REPOST toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "REPOST payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt(); this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32]; this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32); bb.get(this.toBlockHash32);
} else { } else {
this.toBlockchainName = null; this.toLogin = null;
this.toBlockGlobalNumber = null; this.toBlockGlobalNumber = null;
this.toBlockHash32 = null; this.toBlockHash32 = null;
} }
@@ -158,7 +138,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
short subType, short subType,
Integer toBlockGlobalNumber, Integer toBlockGlobalNumber,
byte[] toBlockHash32, byte[] toBlockHash32,
String toBlockchainName, String toLogin,
String message) { String message) {
Objects.requireNonNull(message, "message == null"); Objects.requireNonNull(message, "message == null");
@@ -189,27 +169,29 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.thisLineNumber = thisLineNumber; this.thisLineNumber = thisLineNumber;
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = null; this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { } else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toLogin, "toLogin == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = toBlockchainName; this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else { } else {
this.toBlockchainName = null; this.toLogin = null;
this.toBlockGlobalNumber = null; this.toBlockGlobalNumber = null;
this.toBlockHash32 = null; this.toBlockHash32 = null;
} }
@@ -240,13 +222,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid"); throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32) if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid"); throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
if (toBlockchainName != null) if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName"); throw new IllegalArgumentException("EDIT_POST toLogin is blank");
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { } else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
if (message == null || message.isBlank()) if (message == null || message.isBlank())
throw new IllegalArgumentException("REPOST message is blank"); throw new IllegalArgumentException("REPOST message is blank");
if (toBlockchainName == null || toBlockchainName.isBlank()) if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("REPOST toBlockchainName is blank"); throw new IllegalArgumentException("REPOST toLogin is blank");
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid"); throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32) if (toBlockHash32 == null || toBlockHash32.length != 32)
@@ -259,7 +241,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
} else if (message == null) { } else if (message == null) {
throw new IllegalArgumentException("Text message is null"); throw new IllegalArgumentException("Text message is null");
} }
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null) if (toLogin != null || toBlockGlobalNumber != null || toBlockHash32 != null)
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields"); throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
} }
@@ -284,19 +266,14 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF) || st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) { || st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// EDIT_POST
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
} else { } else {
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName"); if (toLogin == null) throw new IllegalArgumentException("target missing toLogin");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); byte[] nameUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) { if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("REPOST toBlockchainName utf8 len must be 1..255"); throw new IllegalArgumentException("target toLogin utf8 len must be 1..255");
} }
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPOST missing toBlockGlobalNumber"); if (toBlockGlobalNumber == null) throw new IllegalArgumentException("target missing toBlockGlobalNumber");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("target toBlockHash32 != 32");
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length; cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
} }
@@ -307,13 +284,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber); bb.putInt(thisLineNumber);
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
bb.putInt(toBlockGlobalNumber); byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
bb.put(toBlockHash32); bb.put((byte) loginUtf8.length);
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { bb.put(loginUtf8);
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber); bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32); bb.put(toBlockHash32);
} }
@@ -331,7 +305,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
@Override public int lineSeq() { return thisLineNumber; } @Override public int lineSeq() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */ /* ====================== BodyHasTarget ===================== */
@Override public String toBchName() { return toBlockchainName; } @Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -11,14 +11,11 @@ import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
/** /**
* TextRatingBody — type=1, ver=1. * TextRatingBody — type=1, ver=1, subType=30.
*
* subType:
* - RATING (30)
* *
* Формат bodyBytes (BigEndian): * Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8) * [1] toLoginLen (uint8)
* [N] toBlockchainName UTF-8 * [N] toLogin UTF-8
* [4] toBlockGlobalNumber * [4] toBlockGlobalNumber
* [32] toBlockHash32 * [32] toBlockHash32
* [2] textLenBytes (uint16) * [2] textLenBytes (uint16)
@@ -32,7 +29,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
public final short subType; public final short subType;
public final short version; public final short version;
public final String toBlockchainName; public final String toLogin;
public final int toBlockGlobalNumber; public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; public final byte[] toBlockHash32;
public final String message; public final String message;
@@ -52,33 +49,32 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short"); ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
int nameLen = Byte.toUnsignedInt(bb.get()); int loginLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0"); if (loginLen <= 0) throw new IllegalArgumentException("RATING toLoginLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short"); ensureMin(bb, loginLen + 4 + 32 + 2, "RATING payload too short");
byte[] nameBytes = new byte[nameLen]; byte[] loginBytes = new byte[loginLen];
bb.get(nameBytes); bb.get(loginBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt(); this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32]; this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32); bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16(bb, "TextRatingBody text"); this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
ensureNoTail(bb, "TextRatingBody"); ensureNoTail(bb, "TextRatingBody");
} }
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) { public TextRatingBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null"); Objects.requireNonNull(message, "message == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (message.isBlank()) throw new IllegalArgumentException("message is blank"); if (message.isBlank()) throw new IllegalArgumentException("message is blank");
this.subType = MsgSubType.TEXT_RATING; this.subType = MsgSubType.TEXT_RATING;
this.version = VER; this.version = VER;
this.toBlockchainName = toBlockchainName; this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message; this.message = message;
@@ -86,16 +82,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
@Override @Override
public TextRatingBody check() { public TextRatingBody check() {
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) { if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF))
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF)); throw new IllegalArgumentException("Bad RATING subType");
} if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("RATING toLogin is blank");
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("RATING toBlockchainName is blank");
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) { if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank"); if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
return this; return this;
} }
@@ -106,15 +97,14 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) { if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255"); throw new IllegalArgumentException("RATING toLogin utf8 len must be 1..255");
}
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length) ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN); .order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length); bb.put((byte) loginUtf8.length);
bb.put(nameUtf8); bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber); bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32); bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length); bb.putShort((short) msgUtf8.length);
@@ -122,7 +112,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
return bb.array(); return bb.array();
} }
@Override public String toBchName() { return toBlockchainName; } @Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -130,14 +120,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
int len = Short.toUnsignedInt(bb.getShort()); int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty"); if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len]; byte[] bytes = new byte[len];
bb.get(bytes); bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder() var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT) .onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT); .onUnmappableCharacter(CodingErrorAction.REPORT);
try { try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
@@ -17,85 +17,57 @@ import java.util.Objects;
* - REPLY (20) * - REPLY (20)
* - EDIT_REPLY (21) * - EDIT_REPLY (21)
* *
* Форматы bodyBytes (BigEndian): * Оба subtype используют одинаковый target-формат:
* * [1] toLoginLen (uint8)
* REPLY: * [N] toLogin UTF-8
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber * [4] toBlockGlobalNumber
* [32] toBlockHash32 * [32] toBlockHash32
* [2] textLenBytes (uint16) * [2] textLenBytes (uint16)
* [M] text UTF-8 * [M] text UTF-8
* *
* EDIT_REPLY: * Для EDIT_REPLY text может быть пустым (логическое удаление).
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [N] text UTF-8
*/ */
public final class TextReplyBody implements BodyRecord, BodyHasTarget { public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 1; public static final short TYPE = 1;
public static final short VER = 1; public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header public final short subType;
public final short version; // (=1) public final short version;
// target public final String toLogin;
public final String toBlockchainName; // nullable для EDIT_REPLY
public final int toBlockGlobalNumber; public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; // 32 public final byte[] toBlockHash32;
// text
public final String message; public final String message;
public TextReplyBody(short subType, short version, byte[] bodyBytes) { public TextReplyBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null"); Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType; this.subType = subType;
this.version = version; this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF)); throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
} }
int st = this.subType & 0xFFFF; int st = this.subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st); throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
} }
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TextReplyBody too short");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { int loginLen = Byte.toUnsignedInt(bb.get());
// минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2] if (loginLen <= 0) throw new IllegalArgumentException("TextReplyBody toLoginLen is 0");
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short"); ensureMin(bb, loginLen + 4 + 32 + 2, "TextReplyBody payload too short");
int nameLen = Byte.toUnsignedInt(bb.get());
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);
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt(); this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32]; this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32); bb.get(this.toBlockHash32);
} else {
// EDIT_REPLY: target без имени
ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");
this.toBlockchainName = null;
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
}
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)); this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
ensureNoTail(bb, "TextReplyBody"); ensureNoTail(bb, "TextReplyBody");
} }
@@ -103,11 +75,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public TextReplyBody(short subType, public TextReplyBody(short subType,
int toBlockGlobalNumber, int toBlockGlobalNumber,
byte[] toBlockHash32, byte[] toBlockHash32,
String toBlockchainName, String toLogin,
String message) { String message) {
Objects.requireNonNull(message, "message == null"); Objects.requireNonNull(message, "message == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(toLogin, "toLogin == null");
int st = subType & 0xFFFF; int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
@@ -116,25 +88,15 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) { if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
throw new IllegalArgumentException("message is blank"); throw new IllegalArgumentException("message is blank");
} }
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
this.toBlockchainName = toBlockchainName;
} else {
// EDIT_REPLY: имя не хранить
this.toBlockchainName = null;
}
this.subType = subType; this.subType = subType;
this.version = VER; this.version = VER;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message; this.message = message;
} }
@@ -143,23 +105,14 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
int st = subType & 0xFFFF; int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st); throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 invalid");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (message == null || message.isBlank()) if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank");
throw new IllegalArgumentException("Text message is blank"); } else if (message == null) {
if (toBlockchainName == null || toBlockchainName.isBlank()) throw new IllegalArgumentException("EDIT_REPLY message is null");
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
} else {
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
if (toBlockchainName != null)
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
} }
return this; return this;
} }
@@ -167,47 +120,27 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public byte[] toBytes() { public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF; int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) { if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
throw new IllegalArgumentException("Text payload is empty"); throw new IllegalArgumentException("Text payload is empty");
} }
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName"); if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("TextReplyBody toLogin utf8 len must be 1..255");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
if (nameUtf8.length == 0 || nameUtf8.length > 255) .order(ByteOrder.BIG_ENDIAN);
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255"); bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber); bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32); bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length); bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8); bb.put(msgUtf8);
return bb.array(); return bb.array();
} }
// EDIT_REPLY @Override public String toLogin() { return toLogin; }
int cap = (4 + 32) + 2 + msgUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
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 Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -215,8 +148,6 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
} }
/* ====================== helpers ====================== */
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) { private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
int len = Short.toUnsignedInt(bb.getShort()); int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) { if (len == 0) {
@@ -224,14 +155,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
throw new IllegalArgumentException(fieldName + " is empty"); throw new IllegalArgumentException(fieldName + " is empty");
} }
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len]; byte[] bytes = new byte[len];
bb.get(bytes); bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder() var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT) .onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT); .onUnmappableCharacter(CodingErrorAction.REPORT);
try { try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
@@ -0,0 +1,85 @@
package blockchain;
import blockchain.body.ForkBody;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
final class ForkBodyTest {
@Test
void roundTripThroughBodyRecordParser() {
byte[] parentKey = filled(32, 0x11);
byte[] forkHash = filled(32, 0x22);
byte[] tipHash = filled(32, 0x33);
ForkBody source = new ForkBody(
parentKey,
120,
forkHash,
1_700_000_000_000L,
137,
tipHash,
1_700_100_000_000L,
17,
ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK,
"Удаляю неизвестные записи"
).check();
ForkBody parsed = assertInstanceOf(
ForkBody.class,
BodyRecordParser.parse(ForkBody.TYPE, ForkBody.SUBTYPE, ForkBody.VER, source.toBytes())
);
assertArrayEquals(parentKey, parsed.parentBlockchainKey32);
assertEquals(120, parsed.forkPointBlockNumber);
assertArrayEquals(forkHash, parsed.forkPointBlockHash32);
assertEquals(137, parsed.parentTipBlockNumber);
assertArrayEquals(tipHash, parsed.parentTipBlockHash32);
assertEquals(17, parsed.discardedBlocksCount);
assertEquals(ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK, parsed.reasonCode);
assertEquals("Удаляю неизвестные записи", parsed.comment);
}
@Test
void routineRotationMayDiscardNothing() {
ForkBody body = new ForkBody(
filled(32, 1),
10,
filled(32, 2),
1000,
10,
filled(32, 2),
1000,
0,
ForkBody.REASON_ROUTINE_ROTATION,
""
);
assertDoesNotThrow(body::check);
}
@Test
void rejectedWhenDiscardCountDoesNotMatchForkPointAndTip() {
ForkBody body = new ForkBody(
filled(32, 1),
10,
filled(32, 2),
1000,
12,
filled(32, 3),
2000,
99,
ForkBody.REASON_POSSIBLE_COMPROMISE,
""
);
assertThrows(IllegalArgumentException.class, body::check);
}
private static byte[] filled(int size, int value) {
byte[] out = new byte[size];
Arrays.fill(out, (byte) value);
return out;
}
}
@@ -0,0 +1,73 @@
package blockchain;
import blockchain.body.ConnectionBody;
import blockchain.body.ReactionBody;
import blockchain.body.StatusActionBody;
import blockchain.body.TextLineBody;
import blockchain.body.TextReplyBody;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
final class TargetLoginFormatTest {
private static final byte[] HASH = hash(7);
@Test
void reactionRoundTripStoresLoginNotBlockchainName() {
ReactionBody source = new ReactionBody("alice", 42, HASH);
ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check();
assertEquals("alice", parsed.toLogin());
assertEquals(42, parsed.toBlockGlobalNumber());
assertArrayEquals(HASH, parsed.toBlockHashBytes());
assertFalse(new String(source.toBytes()).contains("alice-001"));
}
@Test
void replyAndEditReplyUseSameLoginTargetShape() {
TextReplyBody reply = new TextReplyBody(MsgSubType.TEXT_REPLY, 11, HASH, "alice", "reply");
TextReplyBody parsedReply = new TextReplyBody(MsgSubType.TEXT_REPLY, TextReplyBody.VER, reply.toBytes()).check();
assertEquals("alice", parsedReply.toLogin());
TextReplyBody edit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, 11, HASH, "alice", "");
TextReplyBody parsedEdit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, TextReplyBody.VER, edit.toBytes()).check();
assertEquals("alice", parsedEdit.toLogin());
assertEquals("", parsedEdit.message);
}
@Test
void editPostAndConnectionCarryLoginTarget() {
TextLineBody editPost = new TextLineBody(
0, -1, new byte[32], -1,
MsgSubType.TEXT_EDIT_POST,
5, HASH, "alice", "edited"
);
TextLineBody parsedEditPost = new TextLineBody(MsgSubType.TEXT_EDIT_POST, TextLineBody.VER, editPost.toBytes()).check();
assertEquals("alice", parsedEditPost.toLogin());
ConnectionBody connection = new ConnectionBody(
0, -1, new byte[32], -1,
MsgSubType.CONNECTION_FOLLOW,
"alice", 0, HASH
);
ConnectionBody parsedConnection = new ConnectionBody(MsgSubType.CONNECTION_FOLLOW, ConnectionBody.VER, connection.toBytes()).check();
assertEquals("alice", parsedConnection.toLogin());
}
@Test
void statusActionCarriesLoginTarget() {
StatusActionBody source = new StatusActionBody(MsgSubType.STATUS_STARTED, "alice", 77, HASH, "");
StatusActionBody parsed = new StatusActionBody(MsgSubType.STATUS_STARTED, StatusActionBody.VER, source.toBytes()).check();
assertEquals("alice", parsed.toLogin());
assertEquals(77, parsed.toBlockGlobalNumber());
}
private static byte[] hash(int seed) {
byte[] out = new byte[32];
Arrays.fill(out, (byte) seed);
return out;
}
}
@@ -42,6 +42,8 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_23 = 23; public static final int SCHEMA_VERSION_23 = 23;
public static final int SCHEMA_VERSION_24 = 24; public static final int SCHEMA_VERSION_24 = 24;
public static final int SCHEMA_VERSION_25 = 25; public static final int SCHEMA_VERSION_25 = 25;
public static final int SCHEMA_VERSION_26 = 26;
public static final int SCHEMA_VERSION_27 = 27;
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
@@ -67,6 +69,8 @@ public final class DatabaseInitializer {
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql"; public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql"; public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql"; public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
public static final String POSTGRES_MIGRATION_V26_RESOURCE = "postgres/migration_v26.sql";
public static final String POSTGRES_MIGRATION_V27_RESOURCE = "postgres/migration_v27.sql";
private DatabaseInitializer() {} private DatabaseInitializer() {}
@@ -236,6 +240,14 @@ public final class DatabaseInitializer {
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE); runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
currentVersion = SCHEMA_VERSION_25; currentVersion = SCHEMA_VERSION_25;
} }
if (currentVersion < SCHEMA_VERSION_26) {
runSqlScript(conn, POSTGRES_MIGRATION_V26_RESOURCE);
currentVersion = SCHEMA_VERSION_26;
}
if (currentVersion < SCHEMA_VERSION_27) {
runSqlScript(conn, POSTGRES_MIGRATION_V27_RESOURCE);
currentVersion = SCHEMA_VERSION_27;
}
} }
} }
@@ -127,6 +127,100 @@ public final class BlockchainResyncCleanupDAO {
} }
} }
/**
* После смены fork обновляет только runtime-кэш физического blockchainName у логических target-ссылок.
* Подписанная идентичность target уже задаётся login + blockNumber + blockHash; поэтому смена cache value
* не меняет смысл ссылки и не требует переподписывать чужие блоки.
*/
public void refreshLogicalTargetBlockchainCache(String login, String oldBlockchainName, String newBlockchainName) throws SQLException {
if (login == null || login.isBlank() || newBlockchainName == null || newBlockchainName.isBlank()) {
throw new IllegalArgumentException("login/newBlockchainName are required");
}
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
updateTargetCacheByLogin(c, "blocks", login, newBlockchainName);
updateTargetCacheByLogin(c, "connections_state", login, newBlockchainName);
updateTargetCacheByLogin(c, "reactions_state", login, newBlockchainName);
updateTargetCacheByLogin(c, "message_stats", login, newBlockchainName);
if (oldBlockchainName != null && !oldBlockchainName.isBlank()) {
try (PreparedStatement ps = c.prepareStatement("UPDATE message_views_state SET to_bch_name=? WHERE to_bch_name=?")) {
ps.setString(1, newBlockchainName);
ps.setString(2, oldBlockchainName);
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("UPDATE channel_read_state SET owner_bch_name=? WHERE owner_bch_name=?")) {
ps.setString(1, newBlockchainName);
ps.setString(2, oldBlockchainName);
ps.executeUpdate();
}
}
rebuildInboundMessageCounters(c, login, newBlockchainName);
rebuildStatsState(c);
c.commit();
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to refresh logical target blockchain cache for login=" + login, e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
/**
* После replay candidate-цепочки её собственные message_stats уже существуют, но входящие реакции/ответы
* других пользователей были созданы раньше и не проходили триггеры повторно. Поэтому пересчитываем
* агрегаты из исходных таблиц после перепривязки runtime to_bch_name cache.
*/
private void rebuildInboundMessageCounters(Connection c, String login, String newBlockchainName) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
UPDATE message_stats ms
SET likes_count = (
SELECT COUNT(*)::INTEGER
FROM reactions_state rs
WHERE rs.reaction_type = ?
AND rs.last_sub_type = ?
AND LOWER(rs.to_login) = LOWER(ms.to_login)
AND rs.to_bch_name = ms.to_bch_name
AND rs.to_block_number = ms.to_block_number
AND rs.to_block_hash = ms.to_block_hash
),
replies_count = (
SELECT COUNT(*)::INTEGER
FROM blocks b
WHERE b.msg_type = 1
AND b.msg_sub_type = ?
AND LOWER(b.to_login) = LOWER(ms.to_login)
AND b.to_bch_name = ms.to_bch_name
AND b.to_block_number = ms.to_block_number
AND b.to_block_hash = ms.to_block_hash
)
WHERE LOWER(ms.to_login) = LOWER(?)
AND ms.to_bch_name = ?
""")) {
ps.setInt(1, DatabaseInitializer.REACTION_LIKE);
ps.setInt(2, DatabaseInitializer.REACTION_LIKE);
ps.setInt(3, DatabaseInitializer.TEXT_REPLY);
ps.setString(4, login);
ps.setString(5, newBlockchainName);
ps.executeUpdate();
}
}
private void updateTargetCacheByLogin(Connection c, String table, String login, String newBlockchainName) throws SQLException {
String sql = "UPDATE " + table + " SET to_bch_name=? WHERE LOWER(to_login)=LOWER(?) AND to_bch_name IS DISTINCT FROM ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, newBlockchainName);
ps.setString(2, login);
ps.setString(3, newBlockchainName);
ps.executeUpdate();
}
}
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException { private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
String sql = """ String sql = """
SELECT login SELECT login
@@ -0,0 +1,236 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/** Хранилище candidate-блоков будущего fork во время ротации ключей. */
public final class KeyRotationCandidateBlocksDAO {
private static volatile KeyRotationCandidateBlocksDAO instance;
private final DbController db = DbController.getInstance();
private KeyRotationCandidateBlocksDAO() { }
public static KeyRotationCandidateBlocksDAO getInstance() {
if (instance == null) {
synchronized (KeyRotationCandidateBlocksDAO.class) {
if (instance == null) instance = new KeyRotationCandidateBlocksDAO();
}
}
return instance;
}
/**
* Идемпотентно сохраняет один candidate-блок.
* Повтор того же blockNumber допустим только при полном совпадении hash и DataItem id.
*/
public KeyRotationCandidateBlockEntry insertOrGet(Connection c, KeyRotationCandidateBlockEntry entry) throws SQLException {
KeyRotationCandidateBlockEntry existing = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber());
if (existing != null) {
if (Arrays.equals(existing.getBlockHash(), entry.getBlockHash())
&& Arrays.equals(existing.getDataItemId(), entry.getDataItemId())) {
return existing;
}
throw new SQLException("Candidate block conflict at number=" + entry.getBlockNumber());
}
String sql = """
INSERT INTO key_rotation_candidate_blocks (
rotation_session_id, login, candidate_blockchain_name,
block_number, block_hash, data_item_id, block_bytes,
arweave_publish_pending, arweave_published_at_ms, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, NULL, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
int i = 1;
ps.setLong(i++, entry.getRotationSessionId());
ps.setString(i++, entry.getLogin());
ps.setString(i++, entry.getCandidateBlockchainName());
ps.setInt(i++, entry.getBlockNumber());
ps.setBytes(i++, entry.getBlockHash());
ps.setBytes(i++, entry.getDataItemId());
ps.setBytes(i++, entry.getBlockBytes());
ps.setLong(i++, entry.getCreatedAtMs());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) throw new SQLException("candidate block insert returned no id");
entry.setId(keys.getLong(1));
}
} catch (SQLException insertFailure) {
// Конкурирующий повтор из другой сессии мог успеть вставиться между SELECT и INSERT.
KeyRotationCandidateBlockEntry raced = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber());
if (raced != null
&& Arrays.equals(raced.getBlockHash(), entry.getBlockHash())
&& Arrays.equals(raced.getDataItemId(), entry.getDataItemId())) {
return raced;
}
throw insertFailure;
}
entry.setArweavePublishPending(true);
return entry;
}
public KeyRotationCandidateBlockEntry getByNumber(Connection c, long sessionId, int blockNumber) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT * FROM key_rotation_candidate_blocks
WHERE rotation_session_id = ? AND block_number = ?
""")) {
ps.setLong(1, sessionId);
ps.setInt(2, blockNumber);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? map(rs) : null;
}
}
}
public int countStored(Connection c, long sessionId) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT COUNT(*) FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?
""")) {
ps.setLong(1, sessionId);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getInt(1);
}
}
}
public int countPublished(Connection c, long sessionId) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT COUNT(*) FROM key_rotation_candidate_blocks
WHERE rotation_session_id = ? AND arweave_publish_pending = FALSE
""")) {
ps.setLong(1, sessionId);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getInt(1);
}
}
}
/** Все candidate-блоки одной ротации в строгом порядке block_number. */
public List<KeyRotationCandidateBlockEntry> listBySession(Connection c, long sessionId) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT * FROM key_rotation_candidate_blocks
WHERE rotation_session_id = ?
ORDER BY block_number
""")) {
ps.setLong(1, sessionId);
List<KeyRotationCandidateBlockEntry> out = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(map(rs));
}
return out;
}
}
/** Candidate-блоки имеют приоритет перед обычной очередью publisher-а. */
public List<KeyRotationCandidateBlockEntry> listPendingArweave(int limit) throws SQLException {
if (limit <= 0) return List.of();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
SELECT * FROM key_rotation_candidate_blocks
WHERE arweave_publish_pending = TRUE
ORDER BY id
LIMIT ?
""")) {
ps.setInt(1, limit);
List<KeyRotationCandidateBlockEntry> out = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(map(rs));
}
return out;
}
}
/**
* Помечает DataItem опубликованными и синхронизирует progress_current ротации
* с реальным количеством DataItem, подтверждённых publisher-ом.
*/
public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
if (dataItemIds == null || dataItemIds.isEmpty()) return;
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
java.util.Set<Long> sessions = new java.util.HashSet<>();
try (PreparedStatement find = c.prepareStatement("""
SELECT rotation_session_id
FROM key_rotation_candidate_blocks
WHERE data_item_id = ?
""")) {
for (byte[] id : dataItemIds) {
find.setBytes(1, id);
try (ResultSet rs = find.executeQuery()) {
if (rs.next()) sessions.add(rs.getLong(1));
}
}
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_candidate_blocks
SET arweave_publish_pending = FALSE,
arweave_published_at_ms = COALESCE(arweave_published_at_ms, ?)
WHERE data_item_id = ?
""")) {
for (byte[] id : dataItemIds) {
ps.setLong(1, publishedAtMs);
ps.setBytes(2, id);
ps.addBatch();
}
ps.executeBatch();
}
try (PreparedStatement progress = c.prepareStatement("""
UPDATE key_rotation_sessions s
SET progress_current = LEAST(s.progress_total, (
SELECT COUNT(*)::INTEGER
FROM key_rotation_candidate_blocks b
WHERE b.rotation_session_id = s.id
AND b.arweave_publish_pending = FALSE
)),
updated_at_ms = ?
WHERE s.id = ? AND s.status = 'COPYING_CHAIN'
""")) {
for (Long sessionId : sessions) {
progress.setLong(1, publishedAtMs);
progress.setLong(2, sessionId);
progress.addBatch();
}
progress.executeBatch();
}
c.commit();
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to mark candidate DataItems published", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
private static KeyRotationCandidateBlockEntry map(ResultSet rs) throws SQLException {
KeyRotationCandidateBlockEntry e = new KeyRotationCandidateBlockEntry();
e.setId(rs.getLong("id"));
e.setRotationSessionId(rs.getLong("rotation_session_id"));
e.setLogin(rs.getString("login"));
e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name"));
e.setBlockNumber(rs.getInt("block_number"));
e.setBlockHash(rs.getBytes("block_hash"));
e.setDataItemId(rs.getBytes("data_item_id"));
e.setBlockBytes(rs.getBytes("block_bytes"));
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending"));
e.setArweavePublishedAtMs((Long) rs.getObject("arweave_published_at_ms"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
return e;
}
}
@@ -0,0 +1,610 @@
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 = next == KeyRotationStatus.COMPLETE ? now : current.getCompletedAtMs();
Long aborted = next == KeyRotationStatus.ABORTED ? now : current.getAbortedAtMs();
try (PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET status = ?, updated_at_ms = ?, completed_at_ms = ?, aborted_at_ms = ?
WHERE id = ? AND status = ?
""")) {
ps.setString(1, next.name());
ps.setLong(2, now);
if (completed == null) ps.setNull(3, java.sql.Types.BIGINT); else ps.setLong(3, completed);
if (aborted == null) ps.setNull(4, java.sql.Types.BIGINT); else ps.setLong(4, aborted);
ps.setLong(5, id);
ps.setString(6, expected.name());
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent key rotation transition for id=" + id);
}
KeyRotationStatus userStatus = next.userStatus();
try (PreparedStatement ps = c.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = ?, rotation_session_id = ?
WHERE login = ? AND rotation_session_id = ?
""")) {
ps.setString(1, userStatus.name());
if (userStatus == KeyRotationStatus.NONE) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, id);
ps.setString(3, current.getLogin());
ps.setLong(4, id);
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin());
}
// Candidate-копии нужны только пока ротация активна. В Arweave они уже опубликованы,
// а в PostgreSQL после COMPLETE/ABORTED временные строки больше не нужны.
if (next.isTerminalSessionStatus()) {
try (PreparedStatement ps = c.prepareStatement(
"DELETE FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?")) {
ps.setLong(1, id);
ps.executeUpdate();
}
}
c.commit();
current.setStatus(next);
current.setUpdatedAtMs(now);
current.setCompletedAtMs(completed);
current.setAbortedAtMs(aborted);
return current;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed key rotation transition", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
public void updateProgress(long id, int current, int total) throws SQLException {
if (current < 0 || total < 0 || current > total) {
throw new IllegalArgumentException("Invalid rotation progress: " + current + "/" + total);
}
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET progress_current = ?, progress_total = ?, updated_at_ms = ?
WHERE id = ? AND status = 'COPYING_CHAIN'
""")) {
ps.setInt(1, current);
ps.setInt(2, total);
ps.setLong(3, System.currentTimeMillis());
ps.setLong(4, id);
if (ps.executeUpdate() != 1) throw new SQLException("Rotation progress can only be updated in COPYING_CHAIN, id=" + id);
}
}
/**
* Атомарно фиксирует tx signature и переводит CHAIN_READY -> ROTATING_PDA.
* Это точка, после которой Abort запрещён: отправленная Solana-транзакция может подтвердиться позже.
*/
public KeyRotationSessionEntry beginPdaRotation(long id, String signature) throws SQLException {
if (signature == null || signature.isBlank()) throw new IllegalArgumentException("signature is empty");
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
if (current == null) throw new SQLException("Unknown key rotation session id=" + id);
if (current.getStatus() != KeyRotationStatus.CHAIN_READY) {
throw new SQLException("PDA rotation requires CHAIN_READY, actual=" + current.getStatus());
}
long now = System.currentTimeMillis();
try (PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET status = 'ROTATING_PDA', pda_rotation_signature = ?, updated_at_ms = ?,
last_error = NULL, last_error_at_ms = NULL
WHERE id = ? AND status = 'CHAIN_READY'
""")) {
ps.setString(1, signature);
ps.setLong(2, now);
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation start for id=" + id);
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = 'ROTATING_PDA', rotation_session_id = ?
WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'CHAIN_READY'
""")) {
ps.setLong(1, id);
ps.setString(2, current.getLogin());
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin());
}
c.commit();
current.setStatus(KeyRotationStatus.ROTATING_PDA);
current.setPdaRotationSignature(signature);
current.setUpdatedAtMs(now);
current.setLastError(null);
current.setLastErrorAtMs(null);
return current;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to begin PDA rotation", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
/**
* Если Solana sync уже записал в current-PDA весь ожидаемый новый набор ключей,
* атомарно переводит ROTATING_PDA -> PDA_ROTATED. Иначе возвращает null.
*/
public KeyRotationSessionEntry tryMarkPdaRotatedFromCurrentState(long id) throws SQLException {
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
if (current == null || current.getStatus() != KeyRotationStatus.ROTATING_PDA) {
c.rollback();
return current != null && current.getStatus() == KeyRotationStatus.PDA_ROTATED ? current : null;
}
String root;
String blockchain;
String client;
String blockchainName;
try (PreparedStatement ps = c.prepareStatement("""
SELECT root_key, blockchain_key, client_key, blockchain_name
FROM solana_user_pda_current
WHERE login = ?
FOR UPDATE
""")) {
ps.setString(1, current.getLogin());
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) { c.rollback(); return null; }
root = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("root_key"));
blockchain = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key"));
client = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("client_key"));
blockchainName = rs.getString("blockchain_name");
}
}
if (!current.getNewRootKey().equals(root)
|| !current.getNewBlockchainKey().equals(blockchain)
|| !current.getNewClientKey().equals(client)
|| !current.getCandidateBlockchainName().equals(blockchainName)) {
c.rollback();
return null;
}
long now = System.currentTimeMillis();
try (PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET status = 'PDA_ROTATED', updated_at_ms = ?, last_error = NULL, last_error_at_ms = NULL
WHERE id = ? AND status = 'ROTATING_PDA'
""")) {
ps.setLong(1, now);
ps.setLong(2, id);
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation confirm for id=" + id);
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = 'PDA_ROTATED', rotation_session_id = ?
WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'ROTATING_PDA'
""")) {
ps.setLong(1, id);
ps.setString(2, current.getLogin());
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to current PDA login=" + current.getLogin());
}
c.commit();
current.setStatus(KeyRotationStatus.PDA_ROTATED);
current.setUpdatedAtMs(now);
current.setLastError(null);
current.setLastErrorAtMs(null);
return current;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to confirm rotated PDA", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
public void setPdaRotationSignature(long id, String signature) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET pda_rotation_signature = ?, updated_at_ms = ?
WHERE id = ? AND status = 'ROTATING_PDA'
""")) {
ps.setString(1, signature);
ps.setLong(2, System.currentTimeMillis());
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("PDA signature requires ROTATING_PDA, id=" + id);
}
}
/**
* Фиксирует результат необязательного этапа wallet/DM.
* Имя колонки выбирается только из закрытого списка — внешние значения в SQL не подставляются.
*/
public void setMigrationSubStatus(long id, String column, String value) throws SQLException {
String safeColumn = switch (String.valueOf(column)) {
case "wallet_migration_status" -> "wallet_migration_status";
case "message_migration_status" -> "message_migration_status";
default -> throw new IllegalArgumentException("Unsupported migration status column: " + column);
};
String safeValue = switch (String.valueOf(value)) {
case "PENDING", "COMPLETE", "SKIPPED", "NOT_IMPLEMENTED" -> String.valueOf(value);
default -> throw new IllegalArgumentException("Unsupported migration status value: " + value);
};
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(
"UPDATE key_rotation_sessions SET " + safeColumn + " = ?, updated_at_ms = ? WHERE id = ?")) {
ps.setString(1, safeValue);
ps.setLong(2, System.currentTimeMillis());
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("Unknown key rotation session id=" + id);
}
}
public void recordError(long id, String error) throws SQLException {
long now = System.currentTimeMillis();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET last_error = ?, last_error_at_ms = ?, retry_count = retry_count + 1, updated_at_ms = ?
WHERE id = ? AND status NOT IN ('COMPLETE', 'ABORTED')
""")) {
ps.setString(1, error);
ps.setLong(2, now);
ps.setLong(3, now);
ps.setLong(4, id);
if (ps.executeUpdate() != 1) throw new SQLException("Cannot record error for inactive rotation id=" + id);
}
}
public void clearError(long id) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET last_error = NULL, last_error_at_ms = NULL, updated_at_ms = ?
WHERE id = ?
""")) {
ps.setLong(1, System.currentTimeMillis());
ps.setLong(2, id);
ps.executeUpdate();
}
}
private KeyRotationSessionEntry getByIdForUpdate(Connection c, long id) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ? FOR UPDATE")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
private static String selectBase() {
return """
SELECT
id, login, status,
source_blockchain_name, candidate_blockchain_name,
old_root_key, old_blockchain_key, old_client_key,
new_root_key, new_blockchain_key, new_client_key,
fork_from_block, fork_from_hash,
source_tip_block, source_tip_hash,
reason_code, comment,
progress_current, progress_total,
pda_rotation_signature,
wallet_migration_status, message_migration_status,
last_error, last_error_at_ms, retry_count,
created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms
FROM key_rotation_sessions
""";
}
private static KeyRotationSessionEntry mapRow(ResultSet rs) throws SQLException {
KeyRotationSessionEntry e = new KeyRotationSessionEntry();
e.setId(rs.getLong("id"));
e.setLogin(rs.getString("login"));
e.setStatus(KeyRotationStatus.valueOf(rs.getString("status")));
e.setSourceBlockchainName(rs.getString("source_blockchain_name"));
e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name"));
e.setOldRootKey(rs.getString("old_root_key"));
e.setOldBlockchainKey(rs.getString("old_blockchain_key"));
e.setOldClientKey(rs.getString("old_client_key"));
e.setNewRootKey(rs.getString("new_root_key"));
e.setNewBlockchainKey(rs.getString("new_blockchain_key"));
e.setNewClientKey(rs.getString("new_client_key"));
e.setForkFromBlock(rs.getInt("fork_from_block"));
e.setForkFromHash(rs.getBytes("fork_from_hash"));
e.setSourceTipBlock(rs.getInt("source_tip_block"));
e.setSourceTipHash(rs.getBytes("source_tip_hash"));
e.setReasonCode(rs.getShort("reason_code"));
e.setComment(rs.getString("comment"));
e.setProgressCurrent(rs.getInt("progress_current"));
e.setProgressTotal(rs.getInt("progress_total"));
e.setPdaRotationSignature(rs.getString("pda_rotation_signature"));
e.setWalletMigrationStatus(rs.getString("wallet_migration_status"));
e.setMessageMigrationStatus(rs.getString("message_migration_status"));
e.setLastError(rs.getString("last_error"));
long lastErrorAt = rs.getLong("last_error_at_ms");
e.setLastErrorAtMs(rs.wasNull() ? null : lastErrorAt);
e.setRetryCount(rs.getInt("retry_count"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
long completed = rs.getLong("completed_at_ms");
e.setCompletedAtMs(rs.wasNull() ? null : completed);
long aborted = rs.getLong("aborted_at_ms");
e.setAbortedAtMs(rs.wasNull() ? null : aborted);
return e;
}
private static void validateNewSession(KeyRotationSessionEntry e) {
if (e == null) throw new IllegalArgumentException("entry is null");
if (blank(e.getLogin())) throw new IllegalArgumentException("login is required");
if (blank(e.getSourceBlockchainName()) || blank(e.getCandidateBlockchainName())) {
throw new IllegalArgumentException("source/candidate blockchain names are required");
}
if (blank(e.getOldRootKey()) || blank(e.getOldBlockchainKey()) || blank(e.getOldClientKey())
|| blank(e.getNewRootKey()) || blank(e.getNewBlockchainKey()) || blank(e.getNewClientKey())) {
throw new IllegalArgumentException("all old/new public keys are required");
}
if (e.getForkFromHash() == null || e.getForkFromHash().length != 32) {
throw new IllegalArgumentException("forkFromHash must be 32 bytes");
}
if (e.getSourceTipHash() == null || e.getSourceTipHash().length != 32) {
throw new IllegalArgumentException("sourceTipHash must be 32 bytes");
}
if (e.getForkFromBlock() < 0 || e.getSourceTipBlock() < e.getForkFromBlock()) {
throw new IllegalArgumentException("invalid fork/source tip block numbers");
}
if (e.getReasonCode() < 1 || e.getReasonCode() > 4) {
throw new IllegalArgumentException("reasonCode must be 1..4");
}
String comment = e.getComment() == null ? "" : e.getComment();
if (comment.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1024) {
throw new IllegalArgumentException("comment must be <= 1024 UTF-8 bytes");
}
if (e.getProgressCurrent() < 0 || e.getProgressTotal() < e.getProgressCurrent()) {
throw new IllegalArgumentException("invalid initial progress");
}
}
private static boolean blank(String value) {
return value == null || value.isBlank();
}
}
@@ -37,6 +37,27 @@ public final class SolanaUserPdaCurrentDAO {
} }
} }
public SolanaUserPdaCurrentEntry getByLogin(String login) throws SQLException {
String sql = """
SELECT
login,
blockchain_name,
blockchain_key,
paid_limit_bytes
FROM solana_user_pda_current
WHERE LOWER(login) = LOWER(?)
LIMIT 1
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
}
public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
String sql = """ String sql = """
SELECT SELECT
@@ -0,0 +1,45 @@
package shine.db.entities;
/**
* Candidate-блок будущего fork во время смены ключей.
*
* Эти записи намеренно не попадают в обычную таблицу blocks и поэтому
* не влияют на лайки, ответы, каналы и другие materialized state до
* финального переключения активного fork.
*/
public final class KeyRotationCandidateBlockEntry {
private long id;
private long rotationSessionId;
private String login;
private String candidateBlockchainName;
private int blockNumber;
private byte[] blockHash;
private byte[] dataItemId;
private byte[] blockBytes;
private boolean arweavePublishPending;
private Long arweavePublishedAtMs;
private long createdAtMs;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public long getRotationSessionId() { return rotationSessionId; }
public void setRotationSessionId(long rotationSessionId) { this.rotationSessionId = rotationSessionId; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getCandidateBlockchainName() { return candidateBlockchainName; }
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public byte[] getBlockHash() { return blockHash; }
public void setBlockHash(byte[] blockHash) { this.blockHash = blockHash; }
public byte[] getDataItemId() { return dataItemId; }
public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; }
public byte[] getBlockBytes() { return blockBytes; }
public void setBlockBytes(byte[] blockBytes) { this.blockBytes = blockBytes; }
public boolean isArweavePublishPending() { return arweavePublishPending; }
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
}
@@ -0,0 +1,96 @@
package shine.db.entities;
/**
* Публичное/runtime-состояние одной смены ключей.
* Приватные ключи и пароли в этой сущности не хранятся никогда.
*/
public final class KeyRotationSessionEntry {
private long id;
private String login;
private KeyRotationStatus status;
private String sourceBlockchainName;
private String candidateBlockchainName;
private String oldRootKey;
private String oldBlockchainKey;
private String oldClientKey;
private String newRootKey;
private String newBlockchainKey;
private String newClientKey;
private int forkFromBlock;
private byte[] forkFromHash;
private int sourceTipBlock;
private byte[] sourceTipHash;
private short reasonCode;
private String comment;
private int progressCurrent;
private int progressTotal;
private String pdaRotationSignature;
private String walletMigrationStatus;
private String messageMigrationStatus;
private String lastError;
private Long lastErrorAtMs;
private int retryCount;
private long createdAtMs;
private long updatedAtMs;
private Long completedAtMs;
private Long abortedAtMs;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public KeyRotationStatus getStatus() { return status; }
public void setStatus(KeyRotationStatus status) { this.status = status; }
public String getSourceBlockchainName() { return sourceBlockchainName; }
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
public String getCandidateBlockchainName() { return candidateBlockchainName; }
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
public String getOldRootKey() { return oldRootKey; }
public void setOldRootKey(String oldRootKey) { this.oldRootKey = oldRootKey; }
public String getOldBlockchainKey() { return oldBlockchainKey; }
public void setOldBlockchainKey(String oldBlockchainKey) { this.oldBlockchainKey = oldBlockchainKey; }
public String getOldClientKey() { return oldClientKey; }
public void setOldClientKey(String oldClientKey) { this.oldClientKey = oldClientKey; }
public String getNewRootKey() { return newRootKey; }
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
public String getNewBlockchainKey() { return newBlockchainKey; }
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
public String getNewClientKey() { return newClientKey; }
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
public int getForkFromBlock() { return forkFromBlock; }
public void setForkFromBlock(int forkFromBlock) { this.forkFromBlock = forkFromBlock; }
public byte[] getForkFromHash() { return forkFromHash; }
public void setForkFromHash(byte[] forkFromHash) { this.forkFromHash = forkFromHash; }
public int getSourceTipBlock() { return sourceTipBlock; }
public void setSourceTipBlock(int sourceTipBlock) { this.sourceTipBlock = sourceTipBlock; }
public byte[] getSourceTipHash() { return sourceTipHash; }
public void setSourceTipHash(byte[] sourceTipHash) { this.sourceTipHash = sourceTipHash; }
public short getReasonCode() { return reasonCode; }
public void setReasonCode(short reasonCode) { this.reasonCode = reasonCode; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
public int getProgressCurrent() { return progressCurrent; }
public void setProgressCurrent(int progressCurrent) { this.progressCurrent = progressCurrent; }
public int getProgressTotal() { return progressTotal; }
public void setProgressTotal(int progressTotal) { this.progressTotal = progressTotal; }
public String getPdaRotationSignature() { return pdaRotationSignature; }
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
public String getWalletMigrationStatus() { return walletMigrationStatus; }
public void setWalletMigrationStatus(String walletMigrationStatus) { this.walletMigrationStatus = walletMigrationStatus; }
public String getMessageMigrationStatus() { return messageMigrationStatus; }
public void setMessageMigrationStatus(String messageMigrationStatus) { this.messageMigrationStatus = messageMigrationStatus; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public Long getLastErrorAtMs() { return lastErrorAtMs; }
public void setLastErrorAtMs(Long lastErrorAtMs) { this.lastErrorAtMs = lastErrorAtMs; }
public int getRetryCount() { return retryCount; }
public void setRetryCount(int retryCount) { this.retryCount = retryCount; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
public Long getCompletedAtMs() { return completedAtMs; }
public void setCompletedAtMs(Long completedAtMs) { this.completedAtMs = completedAtMs; }
public Long getAbortedAtMs() { return abortedAtMs; }
public void setAbortedAtMs(Long abortedAtMs) { this.abortedAtMs = abortedAtMs; }
}
@@ -0,0 +1,71 @@
package shine.db.entities;
import java.util.EnumSet;
import java.util.Set;
/**
* Состояния серверной машины смены ключей.
*
* NONE используется только в solana_user_pda_current.rotation_status.
* COMPLETE/ABORTED остаются в истории key_rotation_sessions, после чего
* пользователь снова получает rotation_status=NONE.
*/
public enum KeyRotationStatus {
NONE,
COPYING_CHAIN,
CHAIN_READY,
ROTATING_PDA,
PDA_ROTATED,
REBUILDING_SERVER,
WALLET_MIGRATION,
MESSAGE_MIGRATION,
FINALIZING,
COMPLETE,
ABORTED;
public boolean isTerminalSessionStatus() {
return this == COMPLETE || this == ABORTED;
}
public boolean isUserActiveStatus() {
return this != NONE && !isTerminalSessionStatus();
}
/**
* Допустимые переходы одной ротации.
* Abort сознательно разрешён только до начала ROTATING_PDA: после отправки
* Solana-транзакции нельзя надёжно знать, не будет ли она подтверждена позже.
*/
public boolean canTransitionTo(KeyRotationStatus next) {
if (next == null) return false;
return switch (this) {
case COPYING_CHAIN -> EnumSet.of(CHAIN_READY, ABORTED).contains(next);
case CHAIN_READY -> EnumSet.of(ROTATING_PDA, ABORTED).contains(next);
case ROTATING_PDA -> next == PDA_ROTATED;
case PDA_ROTATED -> next == REBUILDING_SERVER;
case REBUILDING_SERVER -> next == WALLET_MIGRATION;
case WALLET_MIGRATION -> next == MESSAGE_MIGRATION;
case MESSAGE_MIGRATION -> next == FINALIZING;
case FINALIZING -> next == COMPLETE;
case NONE, COMPLETE, ABORTED -> false;
};
}
public static Set<KeyRotationStatus> activeStatuses() {
return EnumSet.of(
COPYING_CHAIN,
CHAIN_READY,
ROTATING_PDA,
PDA_ROTATED,
REBUILDING_SERVER,
WALLET_MIGRATION,
MESSAGE_MIGRATION,
FINALIZING
);
}
/** Статус, который должен храниться у пользователя для данной session-state. */
public KeyRotationStatus userStatus() {
return isTerminalSessionStatus() ? NONE : this;
}
}
@@ -0,0 +1,123 @@
-- v26: серверная state machine смены ключей пользователя.
--
-- Важно:
-- - rotation_status / rotation_session_id являются ЛОКАЛЬНЫМ runtime-состоянием сервера,
-- а не полями Solana PDA;
-- - Solana sync не должен перезаписывать эти колонки при upsert PDA;
-- - приватные ключи здесь никогда не хранятся, только публичные ключи ротации.
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
ALTER TABLE solana_user_pda_current
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
rotation_status IN (
'NONE',
'COPYING_CHAIN',
'CHAIN_READY',
'ROTATING_PDA',
'PDA_ROTATED',
'REBUILDING_SERVER',
'WALLET_MIGRATION',
'MESSAGE_MIGRATION',
'FINALIZING'
)
);
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
ON solana_user_pda_current(rotation_status)
WHERE rotation_status <> 'NONE';
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
id BIGSERIAL PRIMARY KEY,
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (
status IN (
'COPYING_CHAIN',
'CHAIN_READY',
'ROTATING_PDA',
'PDA_ROTATED',
'REBUILDING_SERVER',
'WALLET_MIGRATION',
'MESSAGE_MIGRATION',
'FINALIZING',
'COMPLETE',
'ABORTED'
)
),
source_blockchain_name TEXT NOT NULL,
candidate_blockchain_name TEXT NOT NULL,
old_root_key TEXT NOT NULL,
old_blockchain_key TEXT NOT NULL,
old_client_key TEXT NOT NULL,
new_root_key TEXT NOT NULL,
new_blockchain_key TEXT NOT NULL,
new_client_key TEXT NOT NULL,
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
pda_rotation_signature TEXT,
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
wallet_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
),
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
message_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
),
last_error TEXT,
last_error_at_ms BIGINT,
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
completed_at_ms BIGINT,
aborted_at_ms BIGINT,
CHECK (progress_current <= progress_total),
CHECK (fork_from_block <= source_tip_block)
);
-- У одного login одновременно может существовать только одна незавершённая ротация.
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
ON key_rotation_sessions(login)
WHERE status NOT IN ('COMPLETE', 'ABORTED');
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
ON key_rotation_sessions(login, created_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
ON key_rotation_sessions(status);
-- Быстрый указатель из текущего пользователя на активную/последнюю runtime-сессию.
-- FK добавляется после создания key_rotation_sessions, чтобы не создавать циклический DDL порядок.
ALTER TABLE solana_user_pda_current
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
FOREIGN KEY (rotation_session_id)
REFERENCES key_rotation_sessions(id)
ON DELETE SET NULL;
UPDATE db_schema_version
SET schema_version = 26,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE id = 1;
@@ -0,0 +1,31 @@
BEGIN;
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
id BIGSERIAL PRIMARY KEY,
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
login TEXT NOT NULL,
candidate_blockchain_name TEXT NOT NULL,
block_number INTEGER NOT NULL CHECK (block_number >= 0),
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
block_bytes BYTEA NOT NULL,
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
arweave_published_at_ms BIGINT,
created_at_ms BIGINT NOT NULL,
UNIQUE (rotation_session_id, block_number),
UNIQUE (rotation_session_id, data_item_id)
);
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
ON key_rotation_candidate_blocks(id)
WHERE arweave_publish_pending = TRUE;
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
UPDATE db_schema_version
SET schema_version = 27,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE id = 1;
COMMIT;
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
); );
INSERT INTO db_schema_version (id, schema_version, updated_at_ms) INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 24, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) VALUES (1, 26, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version, schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms; updated_at_ms = EXCLUDED.updated_at_ms;
@@ -2081,8 +2081,111 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
ON solana_user_pda_current(is_server, archive_imported) ON solana_user_pda_current(is_server, archive_imported)
WHERE archive_head_tx_id <> ''; WHERE archive_head_tx_id <> '';
-- Server-local key rotation state machine (schema v26).
-- Эти поля не являются частью Solana PDA и не перезаписываются Solana sync upsert-ом.
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
ALTER TABLE solana_user_pda_current
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
rotation_status IN (
'NONE','COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING'
)
);
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
ON solana_user_pda_current(rotation_status)
WHERE rotation_status <> 'NONE';
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
id BIGSERIAL PRIMARY KEY,
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (
status IN (
'COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING',
'COMPLETE','ABORTED'
)
),
source_blockchain_name TEXT NOT NULL,
candidate_blockchain_name TEXT NOT NULL,
old_root_key TEXT NOT NULL,
old_blockchain_key TEXT NOT NULL,
old_client_key TEXT NOT NULL,
new_root_key TEXT NOT NULL,
new_blockchain_key TEXT NOT NULL,
new_client_key TEXT NOT NULL,
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
pda_rotation_signature TEXT,
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
wallet_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
),
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
message_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
),
last_error TEXT,
last_error_at_ms BIGINT,
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
completed_at_ms BIGINT,
aborted_at_ms BIGINT,
CHECK (progress_current <= progress_total),
CHECK (fork_from_block <= source_tip_block)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
ON key_rotation_sessions(login)
WHERE status NOT IN ('COMPLETE','ABORTED');
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
ON key_rotation_sessions(login, created_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
ON key_rotation_sessions(status);
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
id BIGSERIAL PRIMARY KEY,
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
login TEXT NOT NULL,
candidate_blockchain_name TEXT NOT NULL,
block_number INTEGER NOT NULL CHECK (block_number >= 0),
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
block_bytes BYTEA NOT NULL,
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
arweave_published_at_ms BIGINT,
created_at_ms BIGINT NOT NULL,
UNIQUE (rotation_session_id, block_number),
UNIQUE (rotation_session_id, data_item_id)
);
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
ON key_rotation_candidate_blocks(id)
WHERE arweave_publish_pending = TRUE;
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
ALTER TABLE solana_user_pda_current
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
FOREIGN KEY (rotation_session_id)
REFERENCES key_rotation_sessions(id)
ON DELETE SET NULL;
INSERT INTO db_schema_version(id,schema_version,updated_at_ms) INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) VALUES(1,27,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
COMMIT; COMMIT;
@@ -42,8 +42,25 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetMyBlockchain_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStart_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationFinishChain_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationRotatePda_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStatus_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAbort_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationContinue_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
@@ -191,6 +208,16 @@ public final class JsonHandlerRegistry {
// --- blockchain --- // --- blockchain ---
Map.entry("AddBlock", new Net_AddBlock_Handler()), Map.entry("AddBlock", new Net_AddBlock_Handler()),
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()), Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
Map.entry("GetMyBlockchain", new Net_GetMyBlockchain_Handler()),
// --- key rotation ---
Map.entry("KeyRotationStart", new Net_KeyRotationStart_Handler()),
Map.entry("KeyRotationStatus", new Net_KeyRotationStatus_Handler()),
Map.entry("KeyRotationAddBlock", new Net_KeyRotationAddBlock_Handler()),
Map.entry("KeyRotationFinishChain", new Net_KeyRotationFinishChain_Handler()),
Map.entry("KeyRotationRotatePda", new Net_KeyRotationRotatePda_Handler()),
Map.entry("KeyRotationContinue", new Net_KeyRotationContinue_Handler()),
Map.entry("KeyRotationAbort", new Net_KeyRotationAbort_Handler()),
// --- userParams --- // --- userParams ---
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()), Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
@@ -283,6 +310,16 @@ public final class JsonHandlerRegistry {
// --- blockchain --- // --- blockchain ---
Map.entry("AddBlock", Net_AddBlock_Request.class), Map.entry("AddBlock", Net_AddBlock_Request.class),
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class), Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
Map.entry("GetMyBlockchain", Net_GetMyBlockchain_Request.class),
// --- key rotation ---
Map.entry("KeyRotationStart", Net_KeyRotationStart_Request.class),
Map.entry("KeyRotationStatus", Net_KeyRotationStatus_Request.class),
Map.entry("KeyRotationAddBlock", Net_KeyRotationAddBlock_Request.class),
Map.entry("KeyRotationFinishChain", Net_KeyRotationFinishChain_Request.class),
Map.entry("KeyRotationRotatePda", Net_KeyRotationRotatePda_Request.class),
Map.entry("KeyRotationContinue", Net_KeyRotationContinue_Request.class),
Map.entry("KeyRotationAbort", Net_KeyRotationAbort_Request.class),
// --- userParams --- // --- userParams ---
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class), Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
@@ -98,6 +98,18 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
lock.lock(); lock.lock();
try { try {
try {
if (isKeyRotationActive(blockchainName)) {
BlockchainStateEntry currentState = stateDAO.getByBlockchainName(blockchainName);
int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1;
String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : "";
return error(req, 423, "key_rotation_in_progress", lastNum, lastHash);
}
} catch (Exception e) {
log.error("AddBlock: не удалось проверить key rotation status для {}", blockchainName, e);
return error(req, WireCodes.Status.INTERNAL_ERROR, "key_rotation_check_failed", -1, "");
}
AddBlockResult r = addBlock( AddBlockResult r = addBlock(
blockchainName, blockchainName,
req.getBlockNumber(), // старое поле, пока оставляем req.getBlockNumber(), // старое поле, пока оставляем
@@ -152,6 +164,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return resp; return resp;
} }
private boolean isKeyRotationActive(String blockchainName) throws java.sql.SQLException {
String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName);
if (login == null || login.isBlank()) return false;
try (Connection c = shine.db.DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement("""
SELECT rotation_status
FROM solana_user_pda_current
WHERE normalized_login = LOWER(BTRIM(?))
LIMIT 1
""")) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() && !"NONE".equals(rs.getString(1));
}
}
}
private static String humanMessage(String code) { private static String humanMessage(String code) {
if (code == null) return "Ошибка добавления блока"; if (code == null) return "Ошибка добавления блока";
return switch (code) { return switch (code) {
@@ -184,6 +213,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала"; case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется"; case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
case "key_rotation_in_progress" -> "Сейчас выполняется смена ключей; обычные новые блоки временно запрещены";
case "key_rotation_check_failed" -> "Не удалось проверить состояние смены ключей";
default -> "Ошибка: " + code; default -> "Ошибка: " + code;
}; };
} }
@@ -0,0 +1,98 @@
package server.logic.ws_protocol.JSON.handlers.blockchain;
import blockchain.BchBlockEntry;
import blockchain.body.BodyHasTarget;
import server.logic.ws_protocol.Base64Ws;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.dao.SolanaUserPdaCurrentDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.SolanaUserPdaCurrentEntry;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** Authenticated, paginated current-chain view used by "Мой блокчейн" and key rotation. */
public final class Net_GetMyBlockchain_Handler implements JsonMessageHandler {
private final BlocksDAO blocks = BlocksDAO.getInstance();
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_GetMyBlockchain_Request req = (Net_GetMyBlockchain_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
try {
String login = ctx.getLogin().trim();
SolanaUserPdaCurrentEntry user = users.getByLogin(login);
if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) {
return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "Текущий блокчейн пользователя не найден");
}
String bch = user.getBlockchainName();
BlockchainStateEntry state = states.getByBlockchainName(bch);
int tip = state == null ? -1 : state.getLastBlockNumber();
String tipHash = state == null ? null : hex(state.getLastBlockHash());
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(100, req.getLimit()));
int before = req.getBeforeBlock() == null || req.getBeforeBlock() < 0 ? tip : Math.min(tip, req.getBeforeBlock());
boolean includeBytes = Boolean.TRUE.equals(req.getIncludeBlockBytes());
List<BlockEntry> rows = before < 0
? List.of()
: blocks.listRangeByNumber(bch, Math.max(0, before - limit + 1), before);
Collections.reverse(rows);
List<Net_GetMyBlockchain_Response.BlockItem> items = new ArrayList<>(rows.size());
for (BlockEntry row : rows) {
BchBlockEntry parsed = new BchBlockEntry(row.getBlockBytes());
Net_GetMyBlockchain_Response.BlockItem item = new Net_GetMyBlockchain_Response.BlockItem();
item.setBlockNumber(row.getBlockNumber());
item.setBlockHash(hex(row.getBlockHash()));
item.setPrevBlockHash(hex(parsed.prevHash32));
item.setTimestampMs(parsed.timestamp * 1000L);
item.setMsgType(Short.toUnsignedInt(parsed.type));
item.setMsgSubType(Short.toUnsignedInt(parsed.subType));
item.setMsgVersion(Short.toUnsignedInt(parsed.version));
if (parsed.body instanceof BodyHasTarget target) {
item.setToLogin(target.toLogin());
item.setToBlockNumber(target.toBlockGlobalNumber());
item.setToBlockHash(hex(target.toBlockHashBytes()));
}
if (includeBytes) item.setBlockBytesB64(Base64Ws.encode(row.getBlockBytes()));
items.add(item);
}
Net_GetMyBlockchain_Response resp = new Net_GetMyBlockchain_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(login);
resp.setBlockchainName(bch);
resp.setTipBlockNumber(tip);
resp.setTipBlockHash(tipHash);
resp.setBlocks(items);
int lowest = rows.isEmpty() ? -1 : rows.get(rows.size() - 1).getBlockNumber();
resp.setNextBeforeBlock(lowest > 0 ? lowest - 1 : null);
return resp;
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "GET_MY_BLOCKCHAIN_FAILED", "Не удалось прочитать блокчейн пользователя");
}
}
private static String hex(byte[] bytes) {
if (bytes == null) return null;
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b & 0xff));
return sb.toString();
}
}
@@ -0,0 +1,20 @@
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Paginated read of the authenticated user's current materialized blockchain. */
public final class Net_GetMyBlockchain_Request extends Net_Request {
/** Inclusive highest block number to return. Null/<0 means current tip. */
private Integer beforeBlock;
/** 1..100, default 50. */
private Integer limit;
/** Include complete serialized ANS-104 DataItem for rotation/UI inspection. */
private Boolean includeBlockBytes;
public Integer getBeforeBlock() { return beforeBlock; }
public void setBeforeBlock(Integer beforeBlock) { this.beforeBlock = beforeBlock; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public Boolean getIncludeBlockBytes() { return includeBlockBytes; }
public void setIncludeBlockBytes(Boolean includeBlockBytes) { this.includeBlockBytes = includeBlockBytes; }
}
@@ -0,0 +1,64 @@
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public final class Net_GetMyBlockchain_Response extends Net_Response {
private String login;
private String blockchainName;
private int tipBlockNumber;
private String tipBlockHash;
private Integer nextBeforeBlock;
private List<BlockItem> blocks = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getBlockchainName() { return blockchainName; }
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
public int getTipBlockNumber() { return tipBlockNumber; }
public void setTipBlockNumber(int tipBlockNumber) { this.tipBlockNumber = tipBlockNumber; }
public String getTipBlockHash() { return tipBlockHash; }
public void setTipBlockHash(String tipBlockHash) { this.tipBlockHash = tipBlockHash; }
public Integer getNextBeforeBlock() { return nextBeforeBlock; }
public void setNextBeforeBlock(Integer nextBeforeBlock) { this.nextBeforeBlock = nextBeforeBlock; }
public List<BlockItem> getBlocks() { return blocks; }
public void setBlocks(List<BlockItem> blocks) { this.blocks = blocks; }
public static final class BlockItem {
private int blockNumber;
private String blockHash;
private String prevBlockHash;
private long timestampMs;
private int msgType;
private int msgSubType;
private int msgVersion;
private String toLogin;
private Integer toBlockNumber;
private String toBlockHash;
private String blockBytesB64;
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public String getBlockHash() { return blockHash; }
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
public String getPrevBlockHash() { return prevBlockHash; }
public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; }
public long getTimestampMs() { return timestampMs; }
public void setTimestampMs(long timestampMs) { this.timestampMs = timestampMs; }
public int getMsgType() { return msgType; }
public void setMsgType(int msgType) { this.msgType = msgType; }
public int getMsgSubType() { return msgSubType; }
public void setMsgSubType(int msgSubType) { this.msgSubType = msgSubType; }
public int getMsgVersion() { return msgVersion; }
public void setMsgVersion(int msgVersion) { this.msgVersion = msgVersion; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public Integer getToBlockNumber() { return toBlockNumber; }
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
public String getToBlockHash() { return toBlockHash; }
public void setToBlockHash(String toBlockHash) { this.toBlockHash = toBlockHash; }
public String getBlockBytesB64() { return blockBytesB64; }
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
}
}
@@ -105,7 +105,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
String order = asc ? "ASC" : "DESC"; String order = asc ? "ASC" : "DESC";
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>(); List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(""" try (PreparedStatement ps = c.prepareStatement("""
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
to_login, to_bch_name, to_block_number, to_block_hash
FROM blocks FROM blocks
WHERE login = ? AND msg_type = ? WHERE login = ? AND msg_type = ?
ORDER BY block_number ORDER BY block_number
@@ -132,9 +133,9 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
item.setLikedByMe(false); item.setLikedByMe(false);
item.setRepliesCount(0); item.setRepliesCount(0);
item.setRatingsCount(0); item.setRatingsCount(0);
item.setTargetBlockchainName(statusBody.toBchName()); item.setTargetBlockchainName(rs.getString("to_bch_name"));
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber()); item.setTargetBlockNumber((Integer) rs.getObject("to_block_number"));
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes())); item.setTargetBlockHash(ChannelsReadSupport.toHex(rs.getBytes("to_block_hash")));
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem( List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
c, c,
@@ -148,7 +149,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
item.setVersionsTotal(versions.size()); item.setVersionsTotal(versions.size());
item.setText(versions.get(versions.size() - 1).getText()); item.setText(versions.get(versions.size() - 1).getText());
fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes()); fillTargetDetails(c, item, rs.getString("to_bch_name"),
(Integer) rs.getObject("to_block_number"), rs.getBytes("to_block_hash"));
out.add(item); out.add(item);
} }
} }
@@ -215,7 +217,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
return; return;
} }
try (PreparedStatement ps = c.prepareStatement(""" try (PreparedStatement ps = c.prepareStatement("""
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
to_login, to_bch_name, to_block_number, to_block_hash
FROM blocks FROM blocks
WHERE bch_name = ? AND block_number = ? WHERE bch_name = ? AND block_number = ?
LIMIT 1 LIMIT 1
@@ -0,0 +1,120 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationState_Response;
import shine.db.KeyEncodingUtil;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import java.util.Arrays;
import java.util.Base64;
final class KeyRotationApiSupport {
private KeyRotationApiSupport() {}
static String normalizePublicKey32(String raw) {
String normalized = KeyEncodingUtil.normalizeKeyToBase64_32(raw);
if (normalized == null || normalized.isBlank()) {
throw new IllegalArgumentException("public key is empty");
}
try {
byte[] decoded = Base64.getDecoder().decode(normalized);
if (decoded.length != 32) throw new IllegalArgumentException("public key must be 32 bytes");
return Base64.getEncoder().encodeToString(decoded);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("public key must be Base58/Base64 of 32 bytes", e);
}
}
static byte[] parseHash32(String hex) {
if (hex == null) throw new IllegalArgumentException("hash is null");
String s = hex.trim();
if (s.length() != 64) throw new IllegalArgumentException("hash must contain 64 hex chars");
byte[] out = new byte[32];
for (int i = 0; i < 32; i++) {
int hi = Character.digit(s.charAt(i * 2), 16);
int lo = Character.digit(s.charAt(i * 2 + 1), 16);
if (hi < 0 || lo < 0) throw new IllegalArgumentException("hash contains non-hex chars");
out[i] = (byte) ((hi << 4) | lo);
}
return out;
}
static String toHex(byte[] bytes) {
if (bytes == null) return null;
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b & 0xff));
return sb.toString();
}
static String nextBlockchainName(String login, String source) {
if (login == null || login.isBlank() || source == null || source.length() < 5) {
throw new IllegalArgumentException("bad source blockchain name");
}
String prefix = login + "-";
if (!source.startsWith(prefix) || source.length() != prefix.length() + 3) {
throw new IllegalArgumentException("source blockchain name does not match login");
}
String suffix = source.substring(prefix.length());
if (!suffix.chars().allMatch(Character::isDigit)) {
throw new IllegalArgumentException("bad blockchain suffix");
}
int n = Integer.parseInt(suffix);
if (n >= 999) throw new IllegalArgumentException("blockchain fork limit reached");
return login + "-" + String.format("%03d", n + 1);
}
static void requireKeysChangedAndDistinct(String oldRoot, String oldBlockchain, String oldClient,
String newRoot, String newBlockchain, String newClient) {
java.util.Set<String> oldKeys = java.util.Set.of(oldRoot, oldBlockchain, oldClient);
java.util.Set<String> newKeys = java.util.Set.of(newRoot, newBlockchain, newClient);
if (newKeys.size() != 3) {
throw new IllegalArgumentException("new root/blockchain/client keys must be distinct");
}
for (String newKey : newKeys) {
if (oldKeys.contains(newKey)) {
throw new IllegalArgumentException("new keys must not reuse any key from the old key set");
}
}
}
static boolean hashesEqual(byte[] a, byte[] b) {
return Arrays.equals(a, b);
}
static Net_KeyRotationState_Response response(String op, String requestId, KeyRotationSessionEntry e) {
Net_KeyRotationState_Response r = new Net_KeyRotationState_Response();
r.setOp(op);
r.setRequestId(requestId);
r.setStatus(200);
if (e == null) {
r.setRotationStatus(KeyRotationStatus.NONE.name());
return r;
}
r.setRotationSessionId(e.getId());
r.setRotationStatus(e.getStatus().name());
r.setSourceBlockchainName(e.getSourceBlockchainName());
r.setCandidateBlockchainName(e.getCandidateBlockchainName());
r.setOldRootKey(e.getOldRootKey());
r.setOldBlockchainKey(e.getOldBlockchainKey());
r.setOldClientKey(e.getOldClientKey());
r.setNewRootKey(e.getNewRootKey());
r.setNewBlockchainKey(e.getNewBlockchainKey());
r.setNewClientKey(e.getNewClientKey());
r.setForkFromBlock(e.getForkFromBlock());
r.setForkFromHash(toHex(e.getForkFromHash()));
r.setSourceTipBlock(e.getSourceTipBlock());
r.setSourceTipHash(toHex(e.getSourceTipHash()));
r.setReasonCode(e.getReasonCode());
r.setComment(e.getComment());
r.setProgressCurrent(e.getProgressCurrent());
r.setProgressTotal(e.getProgressTotal());
r.setPdaRotationSignature(e.getPdaRotationSignature());
r.setWalletMigrationStatus(e.getWalletMigrationStatus());
r.setMessageMigrationStatus(e.getMessageMigrationStatus());
r.setLastError(e.getLastError());
r.setRetryCount(e.getRetryCount());
r.setCreatedAtMs(e.getCreatedAtMs());
r.setUpdatedAtMs(e.getUpdatedAtMs());
return r;
}
}
@@ -0,0 +1,36 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
/** Прерывание разрешено только пока Solana-транзакция ротации ещё не могла быть отправлена. */
public final class Net_KeyRotationAbort_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationAbort_Request req = (Net_KeyRotationAbort_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
try {
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
if (current == null) return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), null);
if (current.getStatus() != KeyRotationStatus.COPYING_CHAIN && current.getStatus() != KeyRotationStatus.CHAIN_READY) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_ABORT_TOO_LATE",
"После начала ротации PDA процесс можно только завершить");
}
KeyRotationSessionEntry aborted = rotations.transition(current.getId(), current.getStatus(), KeyRotationStatus.ABORTED);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), aborted);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ABORT_FAILED", "Не удалось прервать смену ключей");
}
}
}
@@ -0,0 +1,224 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import blockchain.BchBlockEntry;
import blockchain.BchCryptoVerifier;
import blockchain.MsgSubType;
import blockchain.body.ForkBody;
import server.logic.ws_protocol.Base64Ws;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.DbController;
import shine.db.dao.BlocksDAO;
import shine.db.dao.KeyRotationCandidateBlocksDAO;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import java.sql.Connection;
import java.util.Arrays;
import java.util.Base64;
import java.util.concurrent.locks.ReentrantLock;
/**
* Принимает DataItem будущего fork во время COPYING_CHAIN.
* Candidate-блок сохраняется отдельно от blocks и не влияет на materialized state.
*/
public final class Net_KeyRotationAddBlock_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
private final BlocksDAO blocks = BlocksDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationAddBlock_Request req = (Net_KeyRotationAddBlock_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
if (req.getBlockNumber() < 0 || req.getBlockBytesB64() == null || req.getBlockBytesB64().isBlank()) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "blockNumber и blockBytesB64 обязательны");
}
String login = ctx.getLogin().trim();
KeyRotationSessionEntry initial;
try {
initial = rotations.getActiveByLogin(login);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_DB_ERROR", "Не удалось прочитать состояние смены ключей");
}
if (initial == null || initial.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация не находится на этапе COPYING_CHAIN");
}
ReentrantLock lock = BlockchainLocks.lockFor(initial.getCandidateBlockchainName());
lock.lock();
try (Connection c = DbController.getInstance().getConnection()) {
KeyRotationSessionEntry session = rotations.getActiveByLogin(c, login);
if (session == null || session.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация больше не находится на этапе COPYING_CHAIN");
}
int stored = candidates.countStored(c, session.getId());
if (req.getBlockNumber() < stored) {
KeyRotationCandidateBlockEntry existing = candidates.getByNumber(c, session.getId(), req.getBlockNumber());
if (existing == null) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CANDIDATE_GAP", "Нарушена последовательность candidate-блоков");
}
BchBlockEntry repeated;
try { repeated = new BchBlockEntry(Base64Ws.decode(req.getBlockBytesB64())); }
catch (Exception e) { return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "Не удалось распарсить DataItem"); }
if (!Arrays.equals(existing.getBlockHash(), repeated.getHash32())
|| !Arrays.equals(existing.getDataItemId(), repeated.getDataItemId32())) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_CONFLICT", "На этом номере уже сохранён другой candidate-блок");
}
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), session);
}
if (req.getBlockNumber() != stored) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_OUT_OF_ORDER", "Candidate-блоки нужно добавлять последовательно с block 0");
}
if (stored >= session.getProgressTotal()) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_ALREADY_COMPLETE", "Все candidate-блоки уже приняты сервером");
}
final byte[] raw;
final BchBlockEntry block;
try {
raw = Base64Ws.decode(req.getBlockBytesB64());
block = new BchBlockEntry(raw);
block.body.check();
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "Некорректный SHiNE/ANS-104 блок");
}
if (block.blockNumber != req.getBlockNumber()) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BLOCK_NUMBER_MISMATCH", "blockNumber запроса не совпадает с блоком");
}
if (!block.getDataItem().hasTag("App", "test5590")) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_APP_TAG", "Отсутствует App=test5590");
}
if (!requestPrevHashMatches(req.getPrevBlockHash(), block.prevHash32)) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_PREV_HASH_MISMATCH", "prevBlockHash запроса не совпадает с блоком");
}
byte[] newBlockchainKey;
try {
newBlockchainKey = Base64.getDecoder().decode(session.getNewBlockchainKey());
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_BAD_STORED_KEY", "Некорректный new blockchain public key в rotation session");
}
if (newBlockchainKey.length != 32 || !BchCryptoVerifier.verifyBlock(block, newBlockchainKey)) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_SIGNATURE", "Candidate DataItem должен быть подписан новым blockchain key");
}
String validationError;
try {
validationError = validateCandidate(c, session, block);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_VALIDATE_FAILED", "Не удалось проверить candidate-блок");
}
if (validationError != null) {
return NetExceptionResponseFactory.error(req, 400, validationError, "Candidate-блок не соответствует выбранному fork");
}
KeyRotationCandidateBlockEntry entry = new KeyRotationCandidateBlockEntry();
entry.setRotationSessionId(session.getId());
entry.setLogin(login);
entry.setCandidateBlockchainName(session.getCandidateBlockchainName());
entry.setBlockNumber(block.blockNumber);
entry.setBlockHash(block.getHash32());
entry.setDataItemId(block.getDataItemId32());
entry.setBlockBytes(raw);
entry.setCreatedAtMs(System.currentTimeMillis());
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
candidates.insertOrGet(c, entry);
c.commit();
} catch (Exception e) {
c.rollback();
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_CONFLICT", "Не удалось сохранить candidate-блок");
} finally {
c.setAutoCommit(oldAutoCommit);
}
// progressCurrent обновляет Arweave publisher только после реальной публикации.
KeyRotationSessionEntry refreshed = rotations.getActiveByLogin(login);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), refreshed == null ? session : refreshed);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ADD_BLOCK_FAILED", "Не удалось принять candidate-блок");
} finally {
lock.unlock();
}
}
private String validateCandidate(Connection c, KeyRotationSessionEntry session, BchBlockEntry candidate) throws Exception {
if (candidate.blockNumber <= session.getForkFromBlock()) {
BlockEntry sourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), candidate.blockNumber);
if (sourceEntry == null || sourceEntry.getBlockBytes() == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
BchBlockEntry source = new BchBlockEntry(sourceEntry.getBlockBytes());
// Перепубликуется тот же Frame и те же ANS-104 tags/target/anchor; меняются только owner/signature/DataItem id.
if (!Arrays.equals(source.getFrameBytes(), candidate.getFrameBytes())) return "KEY_ROTATION_FRAME_MISMATCH";
if (!Arrays.equals(source.getDataItem().rawTags(), candidate.getDataItem().rawTags())) return "KEY_ROTATION_TAGS_MISMATCH";
if (!Arrays.equals(source.getDataItem().target(), candidate.getDataItem().target())) return "KEY_ROTATION_TARGET_MISMATCH";
if (!Arrays.equals(source.getDataItem().anchor(), candidate.getDataItem().anchor())) return "KEY_ROTATION_ANCHOR_MISMATCH";
return null;
}
int techForkNumber = session.getForkFromBlock() + 1;
if (candidate.blockNumber != techForkNumber) return "KEY_ROTATION_UNEXPECTED_BLOCK_NUMBER";
if ((candidate.type & 0xFFFF) != 0
|| (candidate.subType & 0xFFFF) != (MsgSubType.TECH_FORK & 0xFFFF)
|| !(candidate.body instanceof ForkBody fork)) {
return "KEY_ROTATION_TECH_FORK_REQUIRED";
}
if (!Arrays.equals(candidate.prevHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_PREV_HASH";
BlockEntry forkSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getForkFromBlock());
BlockEntry tipSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getSourceTipBlock());
if (forkSourceEntry == null || tipSourceEntry == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
BchBlockEntry forkSource = new BchBlockEntry(forkSourceEntry.getBlockBytes());
BchBlockEntry tipSource = new BchBlockEntry(tipSourceEntry.getBlockBytes());
byte[] oldBlockchain = Base64.getDecoder().decode(session.getOldBlockchainKey());
if (!Arrays.equals(fork.parentBlockchainKey32, oldBlockchain)) return "KEY_ROTATION_TECH_FORK_PARENT_KEY";
if (fork.forkPointBlockNumber != session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_POINT";
if (!Arrays.equals(fork.forkPointBlockHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_POINT_HASH";
if (fork.forkPointTimestampMs != Math.multiplyExact(forkSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_POINT_TIME";
if (fork.parentTipBlockNumber != session.getSourceTipBlock()) return "KEY_ROTATION_TECH_FORK_TIP";
if (!Arrays.equals(fork.parentTipBlockHash32, session.getSourceTipHash())) return "KEY_ROTATION_TECH_FORK_TIP_HASH";
if (fork.parentTipTimestampMs != Math.multiplyExact(tipSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_TIP_TIME";
if (fork.discardedBlocksCount != session.getSourceTipBlock() - session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_DISCARDED";
if (fork.reasonCode != session.getReasonCode()) return "KEY_ROTATION_TECH_FORK_REASON";
if (!normalizeComment(fork.comment).equals(normalizeComment(session.getComment()))) return "KEY_ROTATION_TECH_FORK_COMMENT";
return null;
}
private static boolean requestPrevHashMatches(String raw, byte[] actual) {
if (raw == null || raw.isBlank()) {
return isZero32(actual);
}
try {
return Arrays.equals(KeyRotationApiSupport.parseHash32(raw), actual);
} catch (Exception e) {
return false;
}
}
private static boolean isZero32(byte[] value) {
if (value == null || value.length != 32) return false;
for (byte b : value) if (b != 0) return false;
return true;
}
private static String normalizeComment(String value) {
if (value == null) return "";
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
}
}
@@ -0,0 +1,53 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
/**
* Сейчас wallet/DM migration — явные заглушки. Continue фиксирует NOT_IMPLEMENTED
* и переводит state machine дальше, не делая вид, что данные были реально мигрированы.
*/
public final class Net_KeyRotationContinue_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationContinue_Request req = (Net_KeyRotationContinue_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
try {
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
if (current == null) return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), null);
if (current.getStatus() == KeyRotationStatus.WALLET_MIGRATION) {
rotations.setMigrationSubStatus(current.getId(), "wallet_migration_status", "NOT_IMPLEMENTED");
current = rotations.transition(current.getId(), KeyRotationStatus.WALLET_MIGRATION, KeyRotationStatus.MESSAGE_MIGRATION);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
}
if (current.getStatus() == KeyRotationStatus.MESSAGE_MIGRATION) {
rotations.setMigrationSubStatus(current.getId(), "message_migration_status", "NOT_IMPLEMENTED");
current = rotations.transition(current.getId(), KeyRotationStatus.MESSAGE_MIGRATION, KeyRotationStatus.FINALIZING);
// FINALIZING пока не содержит отдельной пользовательской работы: сразу завершаем.
current = rotations.transition(current.getId(), KeyRotationStatus.FINALIZING, KeyRotationStatus.COMPLETE);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
}
if (current.getStatus() == KeyRotationStatus.FINALIZING) {
current = rotations.transition(current.getId(), KeyRotationStatus.FINALIZING, KeyRotationStatus.COMPLETE);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
}
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CONTINUE_NOT_ALLOWED",
"На текущем этапе продолжение этой операцией не требуется");
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_CONTINUE_FAILED",
"Не удалось продолжить смену ключей");
}
}
}
@@ -0,0 +1,212 @@
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 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 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) {
// Идемпотентность для параллельного 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,11 +216,189 @@ public final class ShineUsersCodec {
int formatMajor = raw[5] & 0xFF; int formatMajor = raw[5] & 0xFF;
int formatMinor = raw[6] & 0xFF; int formatMinor = raw[6] & 0xFF;
if (formatMajor != 1 || formatMinor != 2) { if (formatMajor != 1) {
throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor); throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor);
} }
if (formatMinor == 2) {
return parseUserPdaAccountV12(pdaAddress, slot, rawDataBase64, lastTxSignature, raw); return parseUserPdaAccountV12(pdaAddress, slot, rawDataBase64, lastTxSignature, raw);
} }
if (formatMinor == 0) {
// Временная read-only совместимость: legacy PDA 1.0 снова читается
// server-sync только для bootstrap старых server-записей. Новые записи
// и update по-прежнему работают только с текущим форматом 1.2.
return parseUserPdaAccountV10(pdaAddress, slot, rawDataBase64, lastTxSignature, raw);
}
throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor);
}
private static UserPdaSnapshot parseUserPdaAccountV10(
String pdaAddress,
long slot,
String rawDataBase64,
String lastTxSignature,
byte[] raw
) {
int recordLen = u16le(raw, 7);
if (recordLen < 9 + 64 || recordLen > raw.length) {
throw new IllegalArgumentException("Invalid PDA 1.0 record length");
}
byte[] useful = new byte[recordLen];
System.arraycopy(raw, 0, useful, 0, recordLen);
Reader reader = new Reader(useful);
reader.skip(9);
long createdAtMs = reader.readU64();
long updatedAtMs = reader.readU64();
int recordNumber = (int) reader.readU32();
String prevHash = toHex(reader.readFixed(32));
String login = reader.readStringU8();
int blocksCount = reader.readU8();
String recoveryKey = null;
String rootKey = null;
String clientKey = null;
String blockchainName = null;
String blockchainKey = null;
long paidLimitBytes = 0L;
long usedBytes = 0L;
int lastBlockNumber = 0;
String lastBlockHash = "";
String lastBlockSignature = "";
String arweaveTxId = "";
String archiveHeadTxId = "";
String archiveHeadHash = "";
boolean isServer = false;
int addressFormatType = 0;
int addressFormatVersion = 0;
String serverAddress = "";
List<String> syncServers = new ArrayList<>();
List<String> accessServers = new ArrayList<>();
int sessionsMode = 1;
List<UserSessionSnapshot> sessions = new ArrayList<>();
int trustedCount = 0;
boolean seenRecovery = false;
boolean seenRoot = false;
boolean seenClient = false;
boolean seenBlockchain = false;
for (int i = 0; i < blocksCount; i++) {
int blockType = reader.readU8();
int blockVersion = reader.readU8();
if (blockVersion != BLOCK_VERSION_0) {
throw new IllegalArgumentException("Unsupported PDA 1.0 block version: type=" + blockType + " version=" + blockVersion);
}
switch (blockType) {
case BLOCK_TYPE_RECOVERY_KEY -> {
if (seenRecovery) throw new IllegalArgumentException("Duplicate RecoveryKeyBlock in PDA 1.0");
recoveryKey = Base58Util.encode(reader.readFixed(32));
seenRecovery = true;
}
case BLOCK_TYPE_ROOT_KEY -> {
if (seenRoot) throw new IllegalArgumentException("Duplicate RootKeyBlock in PDA 1.0");
rootKey = Base58Util.encode(reader.readFixed(32));
seenRoot = true;
}
case BLOCK_TYPE_CLIENT_KEY -> {
if (seenClient) throw new IllegalArgumentException("Duplicate ClientKeyBlock in PDA 1.0");
clientKey = Base58Util.encode(reader.readFixed(32));
seenClient = true;
}
case BLOCK_TYPE_BLOCKCHAIN_REGISTRY -> {
if (seenBlockchain) throw new IllegalArgumentException("Duplicate BlockchainRegistryBlock in PDA 1.0");
int count = reader.readU8();
if (count < 1) throw new IllegalArgumentException("Empty BlockchainRegistryBlock in PDA 1.0");
for (int j = 0; j < count; j++) {
int blockchainType = reader.readU8();
String currentName = reader.readStringU8();
String currentKey = Base58Util.encode(reader.readFixed(32));
long currentPaidLimit = reader.readU64();
long currentUsedBytes = reader.readU64();
long currentLastBlockNumber = reader.readU32();
String currentLastBlockHash = toHex(reader.readFixed(32));
String currentLastBlockSignature = Base58Util.encode(reader.readFixed(64));
int arweavePresent = reader.readU8();
String currentArweaveTxId = arweavePresent == 1 ? reader.readStringU8() : "";
if (arweavePresent != 0 && arweavePresent != 1) {
throw new IllegalArgumentException("Invalid PDA 1.0 arweave_present");
}
// Runtime SHiNE использует основной пользовательский blockchain (type=1).
// Если legacy запись содержит несколько chain records, берём первый MAIN_USER.
if (blockchainName == null && blockchainType == BLOCKCHAIN_TYPE_MAIN_USER) {
blockchainName = currentName;
blockchainKey = currentKey;
paidLimitBytes = currentPaidLimit;
usedBytes = currentUsedBytes;
lastBlockNumber = Math.toIntExact(currentLastBlockNumber);
lastBlockHash = currentLastBlockHash;
lastBlockSignature = currentLastBlockSignature;
arweaveTxId = currentArweaveTxId;
}
}
seenBlockchain = true;
}
case BLOCK_TYPE_SERVER_PROFILE -> {
int serverFlag = reader.readU8();
if (serverFlag != 0 && serverFlag != 1) {
throw new IllegalArgumentException("Invalid PDA 1.0 is_server");
}
isServer = serverFlag == 1;
if (isServer) {
addressFormatType = reader.readU8();
addressFormatVersion = reader.readU8();
serverAddress = reader.readStringU8();
int count = reader.readU8();
for (int j = 0; j < count; j++) syncServers.add(reader.readStringU8());
}
}
case BLOCK_TYPE_ACCESS_SERVERS -> {
int count = reader.readU8();
for (int j = 0; j < count; j++) accessServers.add(reader.readStringU8());
}
case BLOCK_TYPE_SESSIONS -> {
sessionsMode = reader.readU8();
int count = reader.readU8();
for (int j = 0; j < count; j++) {
int sessionType = reader.readU8();
int sessionVersion = reader.readU8();
String sessionName = reader.readStringU8();
String sessionPubKey = Base58Util.encode(reader.readFixed(32));
sessions.add(new UserSessionSnapshot(sessionType, sessionVersion, sessionName, sessionPubKey));
}
}
case BLOCK_TYPE_TRUSTED_STATE -> trustedCount = reader.readU8();
case BLOCK_TYPE_ARCHIVE_HEAD -> {
archiveHeadTxId = Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32));
archiveHeadHash = toHex(reader.readFixed(32));
}
default -> throw new IllegalArgumentException("Unsupported PDA 1.0 block type: " + blockType);
}
}
String signature = Base58Util.encode(reader.readFixed(64));
if (reader.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail inside PDA 1.0 record");
}
if (!seenRecovery || !seenRoot || !seenClient || !seenBlockchain
|| recoveryKey == null || rootKey == null || clientKey == null
|| blockchainName == null || blockchainKey == null) {
throw new IllegalArgumentException("PDA 1.0 misses required blocks");
}
return new UserPdaSnapshot(
pdaAddress, login, recordNumber, slot, lastTxSignature,
recoveryKey, rootKey, clientKey, blockchainName, blockchainKey, paidLimitBytes,
usedBytes, lastBlockNumber, lastBlockHash, lastBlockSignature, arweaveTxId,
archiveHeadTxId, archiveHeadHash,
isServer, addressFormatType, addressFormatVersion, serverAddress,
List.copyOf(syncServers), List.copyOf(accessServers), sessionsMode, List.copyOf(sessions), trustedCount,
createdAtMs, updatedAtMs, prevHash, signature, rawDataBase64,
List.of(new BlockchainForkSnapshot(0, blockchainKey, createdAtMs, paidLimitBytes))
);
}
private static ParsedInstruction parseV12PdaMutationInstruction( private static ParsedInstruction parseV12PdaMutationInstruction(
TxKind kind, TxKind kind,
@@ -1,5 +1,6 @@
package sync.storage.postgres; package sync.storage.postgres;
import sync.util.Base58Util;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -272,6 +273,11 @@ public final class PostgresStorageRepository
snapshots snapshots
); );
reconcileConfirmedKeyRotations(
connection,
snapshots
);
upsertSyncState( upsertSyncState(
connection, connection,
newState newState
@@ -305,6 +311,11 @@ public final class PostgresStorageRepository
snapshots snapshots
); );
reconcileConfirmedKeyRotations(
connection,
snapshots
);
upsertSyncState( upsertSyncState(
connection, connection,
newState newState
@@ -536,6 +547,90 @@ public final class PostgresStorageRepository
} }
} }
/**
* Solana является источником истины для момента завершения ротации PDA.
* После upsert current-снимка сверяем все три новых public key и новый blockchainName.
* Совпадение переводит локальную машину ROTATING_PDA -> PDA_ROTATED в той же DB-транзакции.
*/
private void reconcileConfirmedKeyRotations(
Connection connection,
List<ShineUsersCodec.UserPdaSnapshot> snapshots
) throws Exception {
if (snapshots == null || snapshots.isEmpty()) return;
String select = """
SELECT id, new_root_key, new_blockchain_key, new_client_key, candidate_blockchain_name
FROM key_rotation_sessions
WHERE login = ? AND status IN ('CHAIN_READY', 'ROTATING_PDA')
ORDER BY id DESC
LIMIT 1
FOR UPDATE
""";
for (ShineUsersCodec.UserPdaSnapshot snapshot : snapshots) {
if (snapshot == null || snapshot.login() == null || snapshot.login().isBlank()) continue;
try (PreparedStatement ps = connection.prepareStatement(select)) {
ps.setString(1, snapshot.login());
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) continue;
long rotationId = rs.getLong("id");
String expectedRoot = rs.getString("new_root_key");
String expectedBlockchain = rs.getString("new_blockchain_key");
String expectedClient = rs.getString("new_client_key");
String expectedBlockchainName = rs.getString("candidate_blockchain_name");
String actualRoot = keyToBase64(snapshot.rootKey());
String actualBlockchain = keyToBase64(snapshot.blockchainKey());
String actualClient = keyToBase64(snapshot.clientKey());
if (!expectedRoot.equals(actualRoot)
|| !expectedBlockchain.equals(actualBlockchain)
|| !expectedClient.equals(actualClient)
|| !expectedBlockchainName.equals(snapshot.blockchainName())) {
continue;
}
long now = System.currentTimeMillis();
try (PreparedStatement update = connection.prepareStatement("""
UPDATE key_rotation_sessions
SET status = 'PDA_ROTATED',
pda_rotation_signature = COALESCE(pda_rotation_signature, ?),
updated_at_ms = ?,
last_error = NULL,
last_error_at_ms = NULL
WHERE id = ? AND status IN ('CHAIN_READY', 'ROTATING_PDA')
""")) {
update.setString(1, snapshot.lastTxSignature() == null || snapshot.lastTxSignature().isBlank() ? null : snapshot.lastTxSignature());
update.setLong(2, now);
update.setLong(3, rotationId);
if (update.executeUpdate() != 1) continue;
}
try (PreparedStatement update = connection.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = 'PDA_ROTATED', rotation_session_id = ?
WHERE login = ? AND rotation_session_id = ? AND rotation_status IN ('CHAIN_READY', 'ROTATING_PDA')
""")) {
update.setLong(1, rotationId);
update.setString(2, snapshot.login());
update.setLong(3, rotationId);
if (update.executeUpdate() != 1) {
throw new SQLException("Rotation session is not attached to synced login=" + snapshot.login());
}
}
}
}
}
}
private static String keyToBase64(String base58) {
if (base58 == null || base58.isBlank()) return "";
byte[] decoded = Base58Util.decode(base58);
if (decoded.length != 32) return "";
return java.util.Base64.getEncoder().encodeToString(decoded);
}
private void bindSnapshot( private void bindSnapshot(
PreparedStatement statement, PreparedStatement statement,
ShineUsersCodec.UserPdaSnapshot snapshot, ShineUsersCodec.UserPdaSnapshot snapshot,
@@ -0,0 +1,119 @@
package sync.codec;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.*;
class ShineUsersCodecLegacyV10Test {
@Test
void readsLegacyV10ServerPdaForBootstrap() {
byte[] raw = buildLegacyServerPda();
ShineUsersCodec.UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(
"legacy-pda", 123L, Base64.getEncoder().encodeToString(raw), "legacy-tx"
);
assertEquals("legacy_server", snapshot.login());
assertEquals("legacy_server-001", snapshot.blockchainName());
assertEquals(100_000L, snapshot.paidLimitBytes());
assertEquals(12_345L, snapshot.usedBytes());
assertEquals(7, snapshot.lastBlockNumber());
assertTrue(snapshot.isServer());
assertEquals(1, snapshot.addressFormatType());
assertEquals(0, snapshot.addressFormatVersion());
assertEquals("wss://legacy.example/ws", snapshot.serverAddress());
assertEquals(java.util.List.of("sync-old"), snapshot.syncServers());
assertEquals(java.util.List.of("access-old"), snapshot.accessServers());
assertEquals(1, snapshot.blockchainForks().size());
assertEquals(snapshot.blockchainKey(), snapshot.blockchainForks().get(0).blockchainKey());
}
private static byte[] buildLegacyServerPda() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
bytes(out, "SHiNE".getBytes(StandardCharsets.UTF_8));
u8(out, 1);
u8(out, 0);
u16(out, 0); // record_len patch later
u64(out, 1_700_000_000_000L);
u64(out, 1_700_000_001_000L);
u32(out, 3);
bytes(out, repeat(0x11, 32));
str(out, "legacy_server");
u8(out, 7); // recovery, root, client, blockchain, server, access, trusted
u8(out, 0); u8(out, 0); bytes(out, repeat(0x21, 32));
u8(out, 1); u8(out, 0); bytes(out, repeat(0x22, 32));
u8(out, 2); u8(out, 0); bytes(out, repeat(0x23, 32));
u8(out, 3); u8(out, 0);
u8(out, 1);
u8(out, 1);
str(out, "legacy_server-001");
bytes(out, repeat(0x24, 32));
u64(out, 100_000L);
u64(out, 12_345L);
u32(out, 7);
bytes(out, repeat(0x25, 32));
bytes(out, repeat(0x26, 64));
u8(out, 1);
str(out, "legacy-arweave-tx");
u8(out, 30); u8(out, 0);
u8(out, 1);
u8(out, 1);
u8(out, 0);
str(out, "wss://legacy.example/ws");
u8(out, 1);
str(out, "sync-old");
u8(out, 40); u8(out, 0);
u8(out, 1);
str(out, "access-old");
u8(out, 70); u8(out, 0); u8(out, 2);
bytes(out, repeat(0x27, 64));
byte[] record = out.toByteArray();
record[7] = (byte) (record.length & 0xff);
record[8] = (byte) ((record.length >>> 8) & 0xff);
return record;
}
private static byte[] repeat(int value, int count) {
byte[] bytes = new byte[count];
java.util.Arrays.fill(bytes, (byte) value);
return bytes;
}
private static void str(ByteArrayOutputStream out, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
u8(out, bytes.length);
bytes(out, bytes);
}
private static void bytes(ByteArrayOutputStream out, byte[] value) {
out.writeBytes(value);
}
private static void u8(ByteArrayOutputStream out, long value) {
out.write((int) value & 0xff);
}
private static void u16(ByteArrayOutputStream out, long value) {
u8(out, value);
u8(out, value >>> 8);
}
private static void u32(ByteArrayOutputStream out, long value) {
for (int i = 0; i < 4; i++) u8(out, value >>> (8 * i));
}
private static void u64(ByteArrayOutputStream out, long value) {
for (int i = 0; i < 8; i++) u8(out, value >>> (8 * i));
}
}
@@ -0,0 +1,35 @@
package server.keyrotation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Небольшой background worker, который продолжает rebuild даже если UI был закрыт. */
public final class KeyRotationRebuildScheduler {
private static final Logger log = LoggerFactory.getLogger(KeyRotationRebuildScheduler.class);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static final KeyRotationRebuildService SERVICE = new KeyRotationRebuildService();
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override public Thread newThread(Runnable r) {
Thread t = new Thread(r, "key-rotation-rebuild");
t.setDaemon(true);
return t;
}
});
private KeyRotationRebuildScheduler() { }
public static void startOrLog() {
if (!STARTED.compareAndSet(false, true)) return;
EXECUTOR.scheduleWithFixedDelay(() -> {
try { SERVICE.runReady(4); }
catch (Exception e) { log.warn("Key rotation rebuild cycle failed", e); }
}, 1, 2, TimeUnit.SECONDS);
log.info("Key rotation rebuild scheduler started");
}
}
@@ -0,0 +1,166 @@
package server.keyrotation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import server.sync.BlockchainResyncGuard;
import shine.db.dao.BlockchainResyncCleanupDAO;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.KeyRotationCandidateBlocksDAO;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.dao.SolanaUserPdaCurrentDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import shine.db.entities.SolanaUserPdaCurrentEntry;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;
/**
* Автоматически materialize-ит candidate fork после того, как Solana sync подтвердил новое PDA.
* Candidate-блоки уже опубликованы в Arweave; здесь они только становятся единственной рабочей
* цепочкой PostgreSQL и повторно проходят обычный AddBlock validation/projection path.
*/
public final class KeyRotationRebuildService {
private static final Logger log = LoggerFactory.getLogger(KeyRotationRebuildService.class);
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
private final BlockchainResyncCleanupDAO cleanup = BlockchainResyncCleanupDAO.getInstance();
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance();
private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler();
public int runReady(int limit) {
int processed = 0;
try {
for (KeyRotationSessionEntry session : rotations.listByStatus(KeyRotationStatus.PDA_ROTATED, limit)) {
if (tryRebuild(session)) processed++;
}
for (KeyRotationSessionEntry session : rotations.listByStatus(KeyRotationStatus.REBUILDING_SERVER, Math.max(1, limit - processed))) {
if (tryRebuild(session)) processed++;
}
} catch (Exception e) {
log.warn("Key rotation rebuild scan failed", e);
}
return processed;
}
public boolean tryRebuild(KeyRotationSessionEntry session) {
if (session == null) return false;
try {
KeyRotationSessionEntry current = rotations.getById(session.getId());
if (current == null) return false;
if (current.getStatus() == KeyRotationStatus.PDA_ROTATED) {
current = rotations.transition(current.getId(), KeyRotationStatus.PDA_ROTATED, KeyRotationStatus.REBUILDING_SERVER);
}
if (current.getStatus() != KeyRotationStatus.REBUILDING_SERVER) return false;
rebuild(current);
rotations.clearError(current.getId());
rotations.transition(current.getId(), KeyRotationStatus.REBUILDING_SERVER, KeyRotationStatus.WALLET_MIGRATION);
log.info("Key rotation rebuild complete: login={} {} -> {}", current.getLogin(), current.getSourceBlockchainName(), current.getCandidateBlockchainName());
return true;
} catch (Exception e) {
try { rotations.recordError(session.getId(), "REBUILDING_SERVER: " + safeMessage(e)); } catch (Exception ignored) { }
log.warn("Key rotation rebuild failed: id={} login={}", session.getId(), session.getLogin(), e);
return false;
}
}
private void rebuild(KeyRotationSessionEntry session) throws Exception {
verifyCurrentPda(session);
List<KeyRotationCandidateBlockEntry> candidateBlocks;
try (var c = shine.db.DbController.getInstance().getConnection()) {
candidateBlocks = candidates.listBySession(c, session.getId());
}
if (candidateBlocks.size() != session.getProgressTotal()) {
throw new IllegalStateException("candidate count mismatch: " + candidateBlocks.size() + "/" + session.getProgressTotal());
}
for (KeyRotationCandidateBlockEntry block : candidateBlocks) {
if (block.isArweavePublishPending()) {
throw new IllegalStateException("candidate block is not published: #" + block.getBlockNumber());
}
}
String source = session.getSourceBlockchainName();
String target = session.getCandidateBlockchainName();
ReentrantLock first = BlockchainLocks.lockFor(source.compareTo(target) <= 0 ? source : target);
ReentrantLock second = BlockchainLocks.lockFor(source.compareTo(target) <= 0 ? target : source);
first.lock();
second.lock();
boolean sourceGuard = false;
boolean targetGuard = false;
try {
sourceGuard = BlockchainResyncGuard.tryBegin(source);
targetGuard = BlockchainResyncGuard.tryBegin(target);
if (!sourceGuard || !targetGuard) {
throw new IllegalStateException("another resync/rebuild is active");
}
// Повторный запуск безопасен: сначала убираем любую частично materialized candidate-цепочку.
if (states.getByBlockchainName(target) != null) {
cleanup.cleanupBlockchainForFullResync(target);
}
if (states.getByBlockchainName(source) != null) {
cleanup.cleanupBlockchainForFullResync(source);
}
createCandidateState(session);
BlockchainResyncGuard.withBypass(target, () -> {
for (KeyRotationCandidateBlockEntry candidate : candidateBlocks) {
Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(target, candidate.getBlockBytes());
if (!result.ok()) {
throw new IllegalStateException("candidate replay rejected at #" + candidate.getBlockNumber() + ": " + result.reasonCode());
}
}
});
// Чужие подписанные ссылки сохраняют логическую identity login+number+hash.
// Здесь меняется только их локальный cache физического fork.
cleanup.refreshLogicalTargetBlockchainCache(session.getLogin(), source, target);
} finally {
if (targetGuard) BlockchainResyncGuard.end(target);
if (sourceGuard) BlockchainResyncGuard.end(source);
second.unlock();
first.unlock();
}
}
private void verifyCurrentPda(KeyRotationSessionEntry session) throws Exception {
SolanaUserPdaCurrentEntry user = users.getByLogin(session.getLogin());
if (user == null) throw new IllegalStateException("current PDA not found");
if (!session.getCandidateBlockchainName().equals(user.getBlockchainName())) {
throw new IllegalStateException("current PDA blockchainName mismatch");
}
if (!session.getNewBlockchainKey().equals(user.getBlockchainKey())) {
throw new IllegalStateException("current PDA blockchain key mismatch");
}
}
private void createCandidateState(KeyRotationSessionEntry session) throws Exception {
SolanaUserPdaCurrentEntry user = users.getByLogin(session.getLogin());
byte[] key = Base64.getDecoder().decode(session.getNewBlockchainKey());
if (key.length != 32) throw new IllegalStateException("new blockchain key length != 32");
BlockchainStateEntry state = new BlockchainStateEntry();
state.setBlockchainName(session.getCandidateBlockchainName());
state.setLogin(session.getLogin());
state.setBlockchainKey(session.getNewBlockchainKey());
state.setSizeLimit(user != null && user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L);
state.setFileSizeBytes(0L);
state.setLastBlockNumber(-1);
state.setLastBlockHash(null);
state.setUpdatedAtMs(System.currentTimeMillis());
states.insertIfMissing(state);
}
private static String safeMessage(Throwable t) {
String value = t == null ? "unknown" : String.valueOf(t.getMessage());
if (value.length() > 1000) value = value.substring(0, 1000);
return value;
}
}
@@ -1,5 +1,7 @@
package server.ws; package server.ws;
import server.keyrotation.KeyRotationRebuildScheduler;
import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
@@ -61,6 +63,7 @@ public final class WsServer {
// ANS-104: publish locally-created signed user blocks and discover blocks published by other servers. // ANS-104: publish locally-created signed user blocks and discover blocks published by other servers.
ArweaveBlockPublisherScheduler.startOrLog(); ArweaveBlockPublisherScheduler.startOrLog();
ArweaveBlockSyncScheduler.startOrLog(); ArweaveBlockSyncScheduler.startOrLog();
KeyRotationRebuildScheduler.startOrLog();
// ============================================================ // ============================================================
// 2) Запуск Jetty WS // 2) Запуск Jetty WS
@@ -4,7 +4,8 @@ import blockchain.MsgSubType;
import blockchain.body.ConnectionBody; import blockchain.body.ConnectionBody;
import blockchain.body.CreateChannelBody; import blockchain.body.CreateChannelBody;
import blockchain.body.HeaderBody; import blockchain.body.HeaderBody;
import blockchain.body.TextBody; import blockchain.body.TextLineBody;
import blockchain.body.TextReplyBody;
import shine.db.DbController; import shine.db.DbController;
import test.it.blockchain.AddBlockSender; import test.it.blockchain.AddBlockSender;
import test.it.blockchain.ChainState; import test.it.blockchain.ChainState;
@@ -27,7 +28,7 @@ import static org.junit.jupiter.api.Assertions.*;
* CONNECTION (type=3): * CONNECTION (type=3):
* - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber) * - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber)
* - всегда имеет target: * - всегда имеет target:
* toBlockchainName + toBlockGlobalNumber + toBlockHash32 * toLogin + toBlockGlobalNumber + toBlockHash32
* *
* Правило target для связей/подписок: * Правило target для связей/подписок:
* - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0) * - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0)
@@ -88,12 +89,12 @@ public class IT_03_AddBlock_NoAuth {
// POST в канал "0" // POST в канал "0"
{ {
var ln = st1.nextTextLineByRoot(root0); var ln = st1.nextTextLineByRoot(root0);
sender1.send(new TextBody( sender1.send(new TextLineBody(
MsgSubType.TEXT_POST,
root0, root0,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"U1: story/post in channel 0", MsgSubType.TEXT_POST,
null, null, null null, null, null,
"U1: story/post in channel 0"
), t); ), t);
} }
@@ -148,12 +149,12 @@ public class IT_03_AddBlock_NoAuth {
byte[] newsPost0Hash; byte[] newsPost0Hash;
{ {
var ln = st1.nextTextLineByRoot(newsRootBlock); var ln = st1.nextTextLineByRoot(newsRootBlock);
sender1.send(new TextBody( sender1.send(new TextLineBody(
MsgSubType.TEXT_POST,
newsRootBlock, newsRootBlock,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"U1: News post #0", MsgSubType.TEXT_POST,
null, null, null null, null, null,
"U1: News post #0"
), t); ), t);
newsPost0Block = st1.lastBlockNumber(); newsPost0Block = st1.lastBlockNumber();
@@ -164,26 +165,26 @@ public class IT_03_AddBlock_NoAuth {
// POST #1 в канал "News" // POST #1 в канал "News"
{ {
var ln = st1.nextTextLineByRoot(newsRootBlock); var ln = st1.nextTextLineByRoot(newsRootBlock);
sender1.send(new TextBody( sender1.send(new TextLineBody(
MsgSubType.TEXT_POST,
newsRootBlock, newsRootBlock,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"U1: News post #1", MsgSubType.TEXT_POST,
null, null, null null, null, null,
"U1: News post #1"
), t); ), t);
} }
// EDIT_POST (в линии канала) -> target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName) // EDIT_POST -> target на ОРИГИНАЛЬНЫЙ POST по login + number + hash
{ {
var ln = st1.nextTextLineByRoot(newsRootBlock); var ln = st1.nextTextLineByRoot(newsRootBlock);
sender1.send(new TextBody( sender1.send(new TextLineBody(
MsgSubType.TEXT_EDIT_POST,
newsRootBlock, newsRootBlock,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"U1: News post #0 (EDIT)", MsgSubType.TEXT_EDIT_POST,
null,
newsPost0Block, newsPost0Block,
newsPost0Hash newsPost0Hash,
u1,
"U1: News post #0 (EDIT)"
), t); ), t);
} }
@@ -206,17 +207,17 @@ public class IT_03_AddBlock_NoAuth {
// 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2) // 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2)
sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW, sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW,
bch2, u2HeaderBlock, u2HeaderHash, u2, u2HeaderBlock, u2HeaderHash,
"U1 follows U2 (target=U2 HEADER)", t); "U1 follows U2 (target=U2 HEADER)", t);
// 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1) // 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1)
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW, sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
bch1, newsRootBlock, newsRootHash, u1, newsRootBlock, newsRootHash,
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t); "U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
// 3) U2 подписался на второй канал U1 "Updates" // 3) U2 подписался на второй канал U1 "Updates"
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW, sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
bch1, updatesRootBlock, updatesRootHash, u1, updatesRootBlock, updatesRootHash,
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t); "U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
assertEquals(2, countConnectionsByOwner(u2, u1), assertEquals(2, countConnectionsByOwner(u2, u1),
@@ -230,30 +231,31 @@ public class IT_03_AddBlock_NoAuth {
// 4) FRIEND взаимно (на HEADER) // 4) FRIEND взаимно (на HEADER)
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND, sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch2, u2HeaderBlock, u2HeaderHash, u2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: FRIEND", t); "U1 -> U2: FRIEND", t);
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND, sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch1, u1HeaderBlock, u1HeaderHash, u1, u1HeaderBlock, u1HeaderHash,
"U2 -> U1: FRIEND", t); "U2 -> U1: FRIEND", t);
// 5) CONTACT несколько // 5) CONTACT несколько
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT, sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
bch2, u2HeaderBlock, u2HeaderHash, u2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: CONTACT", t); "U1 -> U2: CONTACT", t);
sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT, sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT,
bch1, u1HeaderBlock, u1HeaderHash, u1, u1HeaderBlock, u1HeaderHash,
"U2 -> U1: CONTACT", t); "U2 -> U1: CONTACT", t);
// ========================= // =========================
// USER2 REPLY (ответ в чужой канал) // USER2 REPLY (ответ в чужой канал)
// ========================= // =========================
{ {
sender2.send(TextBody.newReply( sender2.send(new TextReplyBody(
bch1, MsgSubType.TEXT_REPLY,
newsPost0Block, newsPost0Block,
newsPost0Hash, newsPost0Hash,
u1,
"U2: reply to U1 News post #0 (cross-chain)" "U2: reply to U1 News post #0 (cross-chain)"
), t); ), t);
} }
@@ -273,12 +275,12 @@ public class IT_03_AddBlock_NoAuth {
// U1 -> U3: CONTACT // U1 -> U3: CONTACT
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT, sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
bch3, u3HeaderBlock, u3HeaderHash, u3, u3HeaderBlock, u3HeaderHash,
"U1 -> U3: CONTACT", t); "U1 -> U3: CONTACT", t);
// 6) U2 отписывается только от News // 6) U2 отписывается только от News
sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW, sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW,
bch1, newsRootBlock, newsRootHash, u1, newsRootBlock, newsRootHash,
"U2 unfollows U1 channel 'News'", t); "U2 unfollows U1 channel 'News'", t);
assertEquals(1, countConnectionsByOwner(u2, u1), assertEquals(1, countConnectionsByOwner(u2, u1),
@@ -292,7 +294,7 @@ public class IT_03_AddBlock_NoAuth {
// 7) U1 убирает U2 из контактов (UNCONTACT) // 7) U1 убирает U2 из контактов (UNCONTACT)
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT, sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
bch2, u2HeaderBlock, u2HeaderHash, u2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: UNCONTACT", t); "U1 -> U2: UNCONTACT", t);
r.ok("IT_03 сценарий блоков + connections выполнен"); r.ok("IT_03 сценарий блоков + connections выполнен");
@@ -313,7 +315,7 @@ public class IT_03_AddBlock_NoAuth {
private static void sendConnection(AddBlockSender sender, private static void sendConnection(AddBlockSender sender,
ChainState st, ChainState st,
short subType, short subType,
String toBlockchainName, String toLogin,
int toBlockNumber, int toBlockNumber,
byte[] toBlockHash32, byte[] toBlockHash32,
String logNote, String logNote,
@@ -321,7 +323,7 @@ public class IT_03_AddBlock_NoAuth {
if (TestConfig.DEBUG()) { if (TestConfig.DEBUG()) {
TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF) TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF)
+ " to=" + toBlockchainName + " to=" + toLogin
+ " targetBlock=" + toBlockNumber + " targetBlock=" + toBlockNumber
+ " note=" + logNote); + " note=" + logNote);
} }
@@ -330,14 +332,14 @@ public class IT_03_AddBlock_NoAuth {
// КОНСТРУКТОР ИЗ ТВОЕГО КОДА: // КОНСТРУКТОР ИЗ ТВОЕГО КОДА:
// ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, // ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber,
// short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) // short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32)
sender.send(new ConnectionBody( sender.send(new ConnectionBody(
0, // lineCode для connection линии 0, // lineCode для connection линии
ln.prevLineNumber, ln.prevLineNumber,
ln.prevLineHash32, ln.prevLineHash32,
ln.thisLineNumber, ln.thisLineNumber,
subType, subType,
toBlockchainName, toLogin,
toBlockNumber, toBlockNumber,
toBlockHash32 toBlockHash32
), timeout); ), timeout);
@@ -151,7 +151,7 @@ public final class SeedDataPopulationHelper {
line.prevLineHash32, line.prevLineHash32,
line.thisLineNumber, line.thisLineNumber,
relationSubType, relationSubType,
bch(to), to,
0, 0,
targetHeaderHash targetHeaderHash
), timeout); ), timeout);
+10
View File
@@ -113,3 +113,13 @@ cp /path/to/SHiNE-product/application.properties ./application.properties
После обновления `shine_users` серверный модуль `shine-server-solana-users-sync` должен обновляться вместе с программой: текущий codec принимает только PDA 1.2. Legacy PDA 1.0 не мигрируются; тестовые legacy-записи можно закрыть временной инструкцией `close_legacy_pda`. После обновления `shine_users` серверный модуль `shine-server-solana-users-sync` должен обновляться вместе с программой: текущий codec принимает только PDA 1.2. Legacy PDA 1.0 не мигрируются; тестовые legacy-записи можно закрыть временной инструкцией `close_legacy_pda`.
Миграция PostgreSQL v25 добавляет `blockchain_forks_json`. Старые compatibility-колонки продолжают содержать активный (последний) fork, а полный список ключей fork сохраняется отдельно и используется для поиска владельца Arweave-записей по любому историческому blockchain key. Миграция PostgreSQL v25 добавляет `blockchain_forks_json`. Старые compatibility-колонки продолжают содержать активный (последний) fork, а полный список ключей fork сохраняется отдельно и используется для поиска владельца Arweave-записей по любому историческому blockchain key.
## PostgreSQL migration v26: key rotation runtime state
Migration v26 добавляет server-local поля `rotation_status` / `rotation_session_id` в `solana_user_pda_current` и таблицу `key_rotation_sessions`.
Это локальное состояние длительной смены ключей, а не часть Solana PDA. Solana users sync продолжает обновлять только PDA-поля и не должен затирать rotation-state. После обновления сервера миграция применяется стандартным `DatabaseInitializer` автоматически.
## Миграции key rotation
При обновлении сервера DatabaseInitializer последовательно применяет migration v26 (state machine смены ключей) и v27 (отдельные candidate-блоки будущего fork). Ручного создания таблиц не требуется. Candidate-блоки не входят в текущую таблицу `blocks` до финального переключения fork.
@@ -43,6 +43,8 @@
- `bad_block_number` — нарушена последовательность; - `bad_block_number` — нарушена последовательность;
- `bad_prev_hash` — нарушена SHiNE hash chain; - `bad_prev_hash` — нарушена SHiNE hash chain;
- `bad_block_bytes` — DataItem/Frame не парсится. - `bad_block_bytes` — DataItem/Frame не парсится.
- `key_rotation_in_progress` (HTTP/status `423`) — для пользователя уже запущена смена ключей; обычный `AddBlock` временно запрещён до завершения/отмены ротации;
- `key_rotation_check_failed` — сервер не смог проверить локальный rotation status и отклонил запись fail-closed.
## Storage/publish ## Storage/publish
+8
View File
@@ -31,6 +31,14 @@
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии | | `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн | | `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна | | `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна |
| `KeyRotationStart` | `19_Key_Rotation_API.md` | начать смену ключей сразу в состоянии `COPYING_CHAIN` |
| `KeyRotationStatus` | `19_Key_Rotation_API.md` | получить текущий этап и прогресс смены ключей |
| `KeyRotationAddBlock` | `19_Key_Rotation_API.md` | добавить подписанный новым blockchain key candidate-блок будущего fork |
| `KeyRotationFinishChain` | `19_Key_Rotation_API.md` | проверить полную публикацию candidate-chain и перевести её в `CHAIN_READY` |
| `KeyRotationRotatePda` | `19_Key_Rotation_API.md` | зафиксировать отправленную Solana-ротацию PDA и ждать подтверждения sync |
| `KeyRotationContinue` | `19_Key_Rotation_API.md` | продолжить интерактивный post-PDA этап; сейчас wallet/DM отмечаются как `NOT_IMPLEMENTED` и пропускаются |
| `KeyRotationAbort` | `19_Key_Rotation_API.md` | прервать ротацию до отправки Solana PDA-ротации |
| `GetMyBlockchain` | `20_Get_My_Blockchain_API.md` | постранично читать текущую активную собственную цепочку для аудита и выбора fork point |
| `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки | | `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки |
| `Ping` | `05_Technical_Requests_API.md` | keep-alive | | `Ping` | `05_Technical_Requests_API.md` | keep-alive |
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере | | `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
+320
View File
@@ -0,0 +1,320 @@
# Key Rotation API
Этот раздел описывает публичные JSON/WebSocket операции мастера смены ключей пользователя.
Публично доступны:
- `KeyRotationStart`
- `KeyRotationStatus`
- `KeyRotationAddBlock`
- `KeyRotationFinishChain`
- `KeyRotationRotatePda`
- `KeyRotationContinue`
- `KeyRotationAbort`
После `PDA_ROTATED` отдельный фоновый worker автоматически переводит ротацию в `REBUILDING_SERVER`, делает новую candidate-chain единственной рабочей цепочкой PostgreSQL и затем переводит процесс в `WALLET_MIGRATION`.
## Общие правила
- операции доступны только авторизованному LOCAL-пользователю своего access server;
- login берётся из авторизованной сессии и не передаётся в payload;
- приватные ключи и пароли серверу не передаются никогда;
- в БД сохраняются только старые/новые публичные ключи;
- первая серверная запись появляется сразу в состоянии `COPYING_CHAIN`; состояния `PREPARING` в БД нет;
- во время `KeyRotationStart` сервер захватывает тот же per-blockchain lock, что использует обычный `AddBlock`, и фиксирует непротиворечивый source tip.
## `KeyRotationStart`
Создаёт новую серверную сессию ротации.
### Request
```json
{
"op": "KeyRotationStart",
"requestId": "kr-1",
"payload": {
"newRootKey": "<Base58 или Base64 публичного ключа 32B>",
"newBlockchainKey": "<Base58 или Base64 публичного ключа 32B>",
"newClientKey": "<Base58 или Base64 публичного ключа 32B>",
"forkFromBlock": 120,
"forkFromHash": "<64 hex>",
"reasonCode": 1,
"comment": "Плановая смена пароля"
}
}
```
`reasonCode`:
1. обычная ротация;
2. возможная компрометация;
3. подтверждённая компрометация / rollback;
4. recovery.
Сервер самостоятельно берёт из текущего PDA:
- `oldRootKey`;
- `oldBlockchainKey`;
- `oldClientKey`;
- текущий `sourceBlockchainName`.
Также сервер самостоятельно фиксирует текущий tip цепочки. Клиент не может подменить эти значения в запросе.
`forkFromBlock/forkFromHash` обязаны указывать на реально существующий блок текущей активной цепочки.
Новые root/blockchain/client keys должны быть валидными 32-байтовыми публичными ключами, отличаться от соответствующих старых ключей и друг от друга.
После успеха создаётся `key_rotation_sessions` со статусом `COPYING_CHAIN`. `progressTotal` равен количеству будущих candidate-блоков: копия `0..forkFromBlock` плюс `TECH_FORK`.
### Success response
Ответ использует тот же payload состояния, что и `KeyRotationStatus`.
## `KeyRotationStatus`
Возвращает текущее состояние ротации авторизованного пользователя.
### Request
```json
{
"op": "KeyRotationStatus",
"requestId": "kr-status-1",
"payload": {}
}
```
Если активной ротации нет:
```json
{
"op": "KeyRotationStatus",
"requestId": "kr-status-1",
"status": 200,
"ok": true,
"payload": {
"rotationStatus": "NONE"
}
}
```
Если ротация есть, payload содержит:
- `rotationSessionId`;
- `rotationStatus`;
- `sourceBlockchainName` / `candidateBlockchainName`;
- старые и новые публичные root/blockchain/client keys;
- `forkFromBlock` / `forkFromHash`;
- `sourceTipBlock` / `sourceTipHash`;
- `reasonCode` / `comment`;
- `progressCurrent` / `progressTotal`;
- `pdaRotationSignature` (когда появится на последующем этапе);
- `walletMigrationStatus`;
- `messageMigrationStatus`;
- `lastError` / `retryCount`;
- `createdAtMs` / `updatedAtMs`.
Любая авторизованная сессия пользователя может читать этот статус. Состояние ротации принадлежит аккаунту, а не конкретному WebSocket-сеансу.
## `KeyRotationAddBlock`
Принимает один ANS-104 DataItem будущего fork на этапе `COPYING_CHAIN`. Candidate-блоки хранятся отдельно от обычной таблицы `blocks`, поэтому до переключения fork они **не создают лайки, ответы, каналы, связи и другие materialized effects**.
### Request
```json
{
"op": "KeyRotationAddBlock",
"requestId": "kr-block-1",
"payload": {
"blockNumber": 0,
"prevBlockHash": "",
"blockBytesB64": "<полный ANS-104 DataItem в Base64>"
}
}
```
Правила:
- операция доступна только при `rotationStatus=COPYING_CHAIN`;
- блоки принимаются строго последовательно: `0..forkFromBlock`, затем один `TECH_FORK` с номером `forkFromBlock+1`;
- каждый DataItem обязан быть подписан `newBlockchainKey`;
- для копируемых блоков Frame, ANS-104 tags, target и anchor должны полностью совпадать с исходным блоком; меняются только owner/signature/DataItem id;
- последний блок обязан быть `TECH_FORK`, а его parent key, fork point, старый tip, reason/comment и число отброшенных блоков должны совпадать с `KeyRotationStart`;
- повтор уже принятого идентичного блока безопасен и возвращает success; другой DataItem/hash на том же номере возвращает conflict;
- candidate-блоки попадают в отдельную очередь Arweave/Turbo publisher-а и имеют приоритет перед обычными блоками;
- `progressCurrent` увеличивается **только после фактической успешной публикации** DataItem в Arweave/Turbo. Поэтому `KeyRotationStatus` показывает реальный прогресс публикации, а не только приём сервером.
### Основные ошибки
- `KEY_ROTATION_NOT_COPYING` — ротация не находится в `COPYING_CHAIN`;
- `KEY_ROTATION_BLOCK_OUT_OF_ORDER` — пропущен предыдущий candidate-блок;
- `KEY_ROTATION_BLOCK_CONFLICT` — на этом номере уже сохранён другой candidate-блок;
- `KEY_ROTATION_BAD_SIGNATURE` — DataItem подписан не новым blockchain key;
- `KEY_ROTATION_FRAME_MISMATCH` — копируемый Frame отличается от исходной цепочки;
- `KEY_ROTATION_TAGS_MISMATCH` — изменены ANS-104 tags копируемого блока;
- `KEY_ROTATION_TECH_FORK_REQUIRED` — вместо финального `TECH_FORK` передан другой блок;
- ошибки `KEY_ROTATION_TECH_FORK_*` — поля `TECH_FORK` не соответствуют зафиксированной rotation session.
## Основные ошибки `KeyRotationStart`
- `AUTH_REQUIRED` — нет авторизованной сессии;
- `KEY_ROTATION_ALREADY_ACTIVE` — для login уже выполняется ротация;
- `KEY_ROTATION_BAD_FIELDS` — некорректные public keys/hash;
- `KEY_ROTATION_BAD_NEW_KEYS` — ключи не изменились либо новые ключи совпадают друг с другом;
- `KEY_ROTATION_BAD_REASON` — reason вне диапазона `1..4`;
- `KEY_ROTATION_COMMENT_TOO_LONG` — комментарий больше 1024 UTF-8 байт;
- `KEY_ROTATION_BAD_FORK_POINT` — неверная точка fork;
- `KEY_ROTATION_FORK_BLOCK_NOT_FOUND` — выбранный блок отсутствует;
- `KEY_ROTATION_FORK_HASH_MISMATCH` — переданный hash не совпадает с сервером;
- `KEY_ROTATION_SESSION_STALE` — авторизованная сессия содержит уже неактуальный blockchainName.
## `KeyRotationFinishChain`
Финализирует этап построения candidate-chain. Операция **не меняет PDA** и не переключает активную цепочку пользователя: она только доказывает, что будущий fork уже полностью сохранён и опубликован в Arweave/Turbo.
### Request
```json
{
"op": "KeyRotationFinishChain",
"requestId": "kr-finish-chain-1",
"payload": {}
}
```
Перед переходом в `CHAIN_READY` сервер повторно проверяет:
- количество candidate-блоков равно `progressTotal` (`0..forkFromBlock` + один `TECH_FORK`);
- каждый candidate-блок уже подтверждён Arweave/Turbo publisher-ом;
- номера идут строго `0,1,2,...` без дырок;
- `blockHash` и `DataItem id` совпадают с сохранёнными значениями;
- каждый DataItem подписан `newBlockchainKey`;
- `block 0` имеет нулевой `prevHash`, а каждый следующий блок ссылается на hash предыдущего Frame;
- копируемые блоки всё ещё байт-в-байт совпадают с выбранным префиксом исходной цепочки по Frame, tags, target и anchor;
- последний блок является корректным `TECH_FORK` и повторяет зафиксированные fork point, parent tip, reason/comment и число отброшенных блоков.
Только после успешной финальной проверки выполняется:
`COPYING_CHAIN -> CHAIN_READY`.
Повторный `KeyRotationFinishChain`, когда ротация уже находится в `CHAIN_READY`, идемпотентно возвращает success. Это позволяет безопасно вызывать операцию из нескольких сессий или повторить её после потери ответа.
### Основные ошибки
- `KEY_ROTATION_NOT_ACTIVE` — активная ротация отсутствует;
- `KEY_ROTATION_NOT_COPYING` — текущий этап уже не позволяет завершать candidate-chain;
- `KEY_ROTATION_CHAIN_INCOMPLETE` — сервер получил не все candidate-блоки;
- `KEY_ROTATION_CHAIN_NOT_PUBLISHED` — не все DataItem подтверждены Arweave/Turbo publisher-ом;
- `KEY_ROTATION_CANDIDATE_GAP` / `KEY_ROTATION_CHAIN_HASH_MISMATCH` — нарушена последовательность candidate-chain;
- `KEY_ROTATION_BAD_SIGNATURE` — сохранённый candidate не подтверждается новым blockchain key;
- `KEY_ROTATION_TECH_FORK_*` — финальный `TECH_FORK` больше не соответствует rotation session;
- `KEY_ROTATION_FINISH_CHAIN_RACE` — состояние ротации было одновременно изменено другой операцией.
## `KeyRotationRotatePda`
Фиксирует, что клиент уже локально подписал и отправил в Solana транзакцию полной ротации PDA.
Сервер **не получает приватные ключи и не подписывает транзакцию**. Клиент передаёт только публичную Solana transaction signature.
### Request
```json
{
"op": "KeyRotationRotatePda",
"requestId": "kr-rotate-pda-1",
"payload": {
"pdaRotationSignature": "<Base58 Solana transaction signature>"
}
}
```
Операция разрешена только после `CHAIN_READY`. Сервер атомарно сохраняет signature и переводит:
`CHAIN_READY -> ROTATING_PDA`.
После этого отмена ротации уже запрещена: отправленная Solana-транзакция может подтвердиться позже, даже если клиент потерял соединение.
Подтверждением успешной ротации служит **не сам факт наличия signature**, а фактический current PDA, увиденный Solana sync. Sync требует одновременного совпадения:
- `rootKey == newRootKey`;
- `blockchainKey == newBlockchainKey`;
- `clientKey == newClientKey`;
- `blockchainName == candidateBlockchainName`.
Только после этого сервер атомарно переводит:
`ROTATING_PDA -> PDA_ROTATED`.
Если Solana sync успел увидеть новое PDA раньше вызова `KeyRotationRotatePda`, он умеет подтвердить ожидаемые ключи прямо из `CHAIN_READY`. Поэтому потеря клиентского запроса после уже подтверждённой Solana-транзакции не оставляет ротацию зависшей. Если API-вызов всё же приходит, handler идемпотентно возвращает текущее подтверждённое состояние.
Повтор с той же signature идемпотентен. Другая signature для уже начатого `ROTATING_PDA` возвращает conflict.
### Основные ошибки
- `KEY_ROTATION_NOT_ACTIVE` — активной ротации нет;
- `KEY_ROTATION_NOT_CHAIN_READY` — candidate-chain ещё не подтверждена;
- `KEY_ROTATION_BAD_SOLANA_SIGNATURE` — signature отсутствует или не Base58;
- `KEY_ROTATION_PDA_SIGNATURE_CONFLICT` — для этой ротации уже сохранена другая signature;
- `KEY_ROTATION_ROTATE_PDA_FAILED` — внутренняя ошибка фиксации этапа.
## Автоматический rebuild после `PDA_ROTATED`
После подтверждения нового current PDA сервер сам выполняет:
`PDA_ROTATED -> REBUILDING_SERVER -> WALLET_MIGRATION`.
Во время rebuild:
- проверяется, что current PDA действительно указывает на `candidateBlockchainName/newBlockchainKey`;
- старая активная цепочка удаляется из рабочих PostgreSQL-таблиц;
- candidate-блоки повторно проходят обычный `AddBlock` validation/projection path;
- runtime-cache `to_bch_name` у логических ссылок `login + blockNumber + blockHash` перепривязывается к новому fork;
- входящие `likes_count/replies_count` пересчитываются;
- после `COMPLETE` временные строки candidate-chain удаляются из PostgreSQL.
Если rebuild прерывается, `REBUILDING_SERVER` остаётся активным, ошибка записывается в `lastError/retryCount`, а worker безопасно повторяет rebuild.
## `KeyRotationContinue`
Продолжает интерактивные post-PDA этапы. В текущей версии два будущих этапа являются честными заглушками:
- `WALLET_MIGRATION`: `walletMigrationStatus = NOT_IMPLEMENTED`, затем переход в `MESSAGE_MIGRATION`;
- `MESSAGE_MIGRATION`: `messageMigrationStatus = NOT_IMPLEMENTED`, затем `FINALIZING -> COMPLETE`.
Request:
```json
{
"op": "KeyRotationContinue",
"requestId": "kr-continue-1",
"payload": {}
}
```
`KeyRotationContinue` не переводит деньги и не перешифровывает DM. Это специально оставленные точки расширения для будущей реализации.
## `KeyRotationAbort`
Прерывает ротацию только пока Solana-ротация PDA ещё не могла быть отправлена:
- разрешено из `COPYING_CHAIN`;
- разрешено из `CHAIN_READY`;
- начиная с `ROTATING_PDA` отмена запрещена, процесс можно только довести вперёд.
Request:
```json
{
"op": "KeyRotationAbort",
"requestId": "kr-abort-1",
"payload": {}
}
```
При `ABORTED` временные candidate-строки удаляются из PostgreSQL. Уже опубликованные Arweave DataItem остаются неизменяемым сиротским историческим следом и не становятся активной цепочкой, поскольку PDA не переключён.
+46
View File
@@ -0,0 +1,46 @@
# GetMyBlockchain API
`GetMyBlockchain` — авторизованное постраничное чтение **только текущей активной версии собственного блокчейна**. API используется постоянным экраном «Мой блокчейн» и мастером смены ключей для выбора последнего доверенного блока.
Login и активный `blockchainName` сервер определяет из авторизованной сессии/current PDA; клиент не может запросить этим методом чужую цепочку.
## Request
```json
{
"op": "GetMyBlockchain",
"requestId": "my-bch-1",
"payload": {
"beforeBlock": 500,
"limit": 50,
"includeBlockBytes": false
}
}
```
Поля:
- `beforeBlock` — необязательный номер верхней границы страницы; если отсутствует, чтение начинается с current tip;
- `limit` — `1..100`, default `50`;
- `includeBlockBytes` — при `true` дополнительно вернуть полный ANS-104 DataItem в Base64.
## Response
Payload содержит:
- `login`;
- `blockchainName`;
- `tipBlockNumber` / `tipBlockHash`;
- `nextBeforeBlock` для следующей страницы;
- `blocks[]` в порядке от новых к старым.
Каждый элемент `blocks[]` содержит:
- `blockNumber`;
- `blockHash` / `prevBlockHash`;
- `timestampMs`;
- `msgType` / `msgSubType` / `msgVersion`;
- для target-блоков: `toLogin + toBlockNumber + toBlockHash`;
- `blockBytesB64`, только если запрошен `includeBlockBytes=true`.
После fork API показывает только новую активную ветку PostgreSQL. Исторические fork при необходимости восстанавливаются из Arweave/PDA history отдельным будущим viewer-механизмом.
@@ -15,7 +15,7 @@
## Быстрая карта типов ## Быстрая карта типов
- `type=0` — TECH: HEADER, CREATE_CHANNEL. - `type=0` — TECH: HEADER, CREATE_CHANNEL, FORK.
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE. - `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE.
- `type=2` — REACTION: LIKE/UNLIKE. - `type=2` — REACTION: LIKE/UNLIKE.
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции. - `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
+37 -1
View File
@@ -12,7 +12,43 @@ TECH-тип покрывает системные записи цепочки.
- создание нового канала; - создание нового канала;
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`. - хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`.
3. `subType=2` — `TECH_FORK`
- первый новый блок после точной перепубликации выбранного префикса предыдущего fork новым blockchain key;
- связывает новую активную цепочку с предыдущей и фиксирует точку rollback/продолжения.
### `TECH_FORK` body (`version=1`)
Big-endian:
- `parentBlockchainKey[32]` — public key предыдущего fork;
- `forkPointBlockNumber[4]` — последний блок старой цепочки, сохранённый в новом fork;
- `forkPointBlockHash32[32]`;
- `forkPointTimestampMs[8]`;
- `parentTipBlockNumber[4]` — tip старой цепочки на момент начала ротации;
- `parentTipBlockHash32[32]`;
- `parentTipTimestampMs[8]`;
- `discardedBlocksCount[4]` — `parentTipBlockNumber - forkPointBlockNumber`;
- `reasonCode[1]`;
- `commentUtf8Length[2]`;
- `comment[N]` — произвольный комментарий пользователя, максимум 1024 UTF-8 байт.
`reasonCode`:
- `1` — `ROUTINE_ROTATION`: обычная смена пароля/ключей, компрометация не предполагается;
- `2` — `POSSIBLE_COMPROMISE`: возможная компрометация, неизвестные записи не подтверждены;
- `3` — `CONFIRMED_COMPROMISE_ROLLBACK`: обнаружены нежелательные/чужие записи и выполнен rollback;
- `4` — `RECOVERY`: восстановление доступа recovery-механизмом.
Правила:
- блоки `0..forkPointBlockNumber` в новом fork должны быть точными Frame-копиями выбранного префикса предыдущей цепочки;
- `TECH_FORK` идёт сразу после этого префикса и является первым действительно новым Frame нового fork;
- если история сохранена полностью, `forkPointBlockNumber == parentTipBlockNumber` и `discardedBlocksCount == 0`;
- если сохраняется только genesis, новый fork содержит прежний `block 0`, а `block 1` является `TECH_FORK`;
- новый blockchain key в body не дублируется: он определяется owner/signature нового ANS-104 DataItem.
## Назначение ## Назначение
- инициализация блокчейна; - инициализация блокчейна;
- управление набором каналов пользователя. - управление набором каналов пользователя;
- фиксация происхождения нового fork и причины ротации/rollback.
+16 -2
View File
@@ -14,7 +14,7 @@ TEXT-тип хранит сообщения, материалы и редакт
3. `subType=20` — `TEXT_REPLY` 3. `subType=20` — `TEXT_REPLY`
- ответ на сообщение; - ответ на сообщение;
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст. - target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
4. `subType=21` — `TEXT_EDIT_REPLY` 4. `subType=21` — `TEXT_EDIT_REPLY`
- редактирование ответа; - редактирование ответа;
@@ -23,7 +23,7 @@ TEXT-тип хранит сообщения, материалы и редакт
5. `subType=30` — `TEXT_RATING` 5. `subType=30` — `TEXT_RATING`
- target-based отзыв на конкретный блок; - target-based отзыв на конкретный блок;
- содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва; - содержит target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
- не является сообщением линии канала. - не является сообщением линии канала.
6. `subType=50` — `TEXT_REPOST` 6. `subType=50` — `TEXT_REPOST`
@@ -57,6 +57,20 @@ TEXT-тип хранит сообщения, материалы и редакт
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md). Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
## Общий target-формат TEXT
Для `TEXT_EDIT_POST`, `TEXT_REPLY`, `TEXT_EDIT_REPLY`, `TEXT_RATING` и `TEXT_REPOST` ссылка на цель хранится как:
```text
[1] toLoginLen (uint8)
[N] toLogin UTF-8
[4] toBlockGlobalNumber
[32] toBlockHash32
```
`blockchainName`/номер fork в подписываемые байты target не входит. Одинаковые `login + blockNumber + blockHash` считаются одной логической целью после перепубликации сохранённого префикса при fork.
## Правило для edit ## Правило для edit
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit. `EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
+16 -2
View File
@@ -4,11 +4,25 @@
1. `subType=1` — `REACTION_LIKE` 1. `subType=1` — `REACTION_LIKE`
- лайк на целевой блок; - лайк на целевой блок;
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`. - хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
2. `subType=2` — `REACTION_UNLIKE` 2. `subType=2` — `REACTION_UNLIKE`
- снятие лайка с целевого блока; - снятие лайка с целевого блока;
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`. - хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
## Назначение ## Назначение
- реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой). - реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой).
## Формат target
В подписанных байтах target больше не хранит имя fork/blockchain. Формат:
```text
[1] toLoginLen (uint8)
[N] toLogin UTF-8
[4] toBlockGlobalNumber
[32] toBlockHash32
```
Логическая идентичность цели: `toLogin + toBlockGlobalNumber + toBlockHash32`. Поэтому ссылка остаётся той же после fork, если номер и hash исходного блока сохранены.
+12 -1
View File
@@ -18,7 +18,18 @@ CONNECTION-тип описывает социальные связи и подп
## Общий формат payload ## Общий формат payload
- line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`) - line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`)
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) - target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`)
## Бинарный target
```text
[1] toLoginLen (uint8)
[N] toLogin UTF-8
[4] toBlockGlobalNumber
[32] toBlockHash32
```
Имя fork/blockchain в target не хранится.
## Правила target ## Правила target
+3 -3
View File
@@ -36,8 +36,8 @@
Все `STATUS_ACTION` используют один и тот же бинарный body-формат: Все `STATUS_ACTION` используют один и тот же бинарный body-формат:
```text ```text
[1] toBlockchainNameLen (uint8) [1] toLoginLen (uint8)
[N] toBlockchainName UTF-8 [N] toLogin UTF-8
[4] toBlockGlobalNumber [4] toBlockGlobalNumber
[32] toBlockHash32 [32] toBlockHash32
[2] textLenBytes (uint16) [2] textLenBytes (uint16)
@@ -46,7 +46,7 @@
Где: Где:
- `toBlockchainName` — блокчейн, в котором находится целевой материал; - `toLogin` — login владельца целевого блока; номер fork в target не хранится;
- `toBlockGlobalNumber` — номер целевого блока; - `toBlockGlobalNumber` — номер целевого блока;
- `toBlockHash32` — хэш целевого блока; - `toBlockHash32` — хэш целевого блока;
- `text` — опциональное пояснение пользователя к статусу. - `text` — опциональное пояснение пользователя к статусу.
+7
View File
@@ -1,3 +1,10 @@
## 2026-09-27 — TECH_FORK v1
- Добавлен `type=0 / subType=2 / version=1` (`TECH_FORK`).
- `TECH_FORK` фиксирует parent blockchain key, точку сохранённой истории, старый tip, число отброшенных блоков, reason code и пользовательский комментарий.
- Новый blockchain key не дублируется в body: он определяется подписью нового ANS-104 DataItem.
- Зафиксированы четыре причины: обычная ротация, возможная компрометация, подтверждённая компрометация с rollback и recovery.
# История изменений документации блокчейна # История изменений документации блокчейна
## 2026-09-23 — Turbo transport для individual ANS-104 DataItems ## 2026-09-23 — Turbo transport для individual ANS-104 DataItems
+19 -19
View File
@@ -2,10 +2,10 @@
> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.** > **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.**
> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся > Этот файл описывает, как из пароля получается секрет и как из секрета выводятся
> все ключи (root, blockchain, device/Solana, homeserver) — формулами, байт-в-байт. > все ключи (root, blockchain, client, homeserver) — формулами, байт-в-байт.
> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула > Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула
> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь > ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь
> dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**. > blockchain key ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
> Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура). > Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура).
> Здесь — только механика. Документ намеренно краткий. > Здесь — только механика. Документ намеренно краткий.
@@ -55,29 +55,29 @@ seed(32) = SHA-256(material)
| Ключ | Суффикс | Назначение (кратко) | | Ключ | Суффикс | Назначение (кратко) |
|------|---------|---------------------| |------|---------|---------------------|
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). | | root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). | | blockchain | `blockchain.key` | Подписывает пользовательские блоки/ANS-104 DataItem и является текущим Solana-wallet/fee payer. |
| device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. | | client | `client.key` | Общий клиентский ключ для DM/устройств и derivation отдельного Arweave SAWD-кошелька; не является текущим Solana-wallet. |
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. | | homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
Полные роли каждого ключа — в `docs/Keys/README.md`. Полные роли каждого ключа — в `docs/Keys/README.md`.
--- ---
## 3. Solana-ключ ## 3. Solana-кошелёк и authority
Отдельного «солана-ключа» нет. На Solana работают два ключа: Отдельного «solana.key» нет. В актуальной модели Solana-wallet пользователя — **активный `blockchain.key`**:
- **`client.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(clientPub)`. - адрес кошелька = `base58(activeBlockchainPub)`;
Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`. - им оплачивается регистрация (`create_user_pda`) и обычные `update_user_pda`;
Пополнять SOL нужно именно на этот адрес. - обычное обновление PDA авторизуется активным blockchain key;
- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer. - новый fork подписывает новое PDA новым blockchain key, а старый активный blockchain key разрешает обычный переход;
- полная смена пароля/ключей — особый recovery-переход: старый `root.key` дополнительно разрешает одно новое unsigned PDA state, а **новый blockchain key** подписывает тот же hash и становится новым wallet/authority.
Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1 `root.key` не является fee payer. Он используется как холодное дополнительное разрешение только для полной ротации root + client + blockchain fork.
(«create/update оплачиваются с `client_key`», «root_key — не fee payer»).
Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью `client.key` больше не используется как Solana-wallet/fee payer. Он остаётся клиентским криптографическим ключом (DM/устройства) и входом в отдельный протокол derivation Arweave-кошелька.
(`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый
кошелёк и плательщик комиссий**. Полное описание ролей — `docs/Keys/README.md`. При fork Solana-адрес меняется вместе с blockchain key. Перенос SOL со старого blockchain-wallet на новый является отдельным необязательным этапом ротации; в текущей первой реализации этот этап оставлен явной заглушкой.
--- ---
@@ -117,9 +117,9 @@ homeserver.key:home-b -> ключ B
- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160). - `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160).
### Solana-ключ / адрес кошелька (UI) ### Solana-ключ / адрес кошелька (UI)
- `shine-UI/js/pages/registration-payment-view.js` — `deriveUserWalletAddress`: адрес = `base58(clientPub)` (~113). - `shine-UI/js/pages/registration-payment-view.js` — адрес пополнения = `base58(blockchainPub)`.
- `shine-UI/js/pages/topup-view.js` — `clientWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.clientPair`. - `shine-UI/js/pages/topup-view.js` — тот же адрес из `preGeneratedKeyBundle.blockchainPair`.
Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `client.key`, мимо `masterSecret`) удалён. - `shine-UI/js/services/solana-wallet-service.js` — текущий пользовательский Solana-wallet загружается из сохранённого `blockchainKey`.
### Деривация ключей (прошивка ESP32) ### Деривация ключей (прошивка ESP32)
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino` - `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
@@ -147,7 +147,7 @@ homeserver.key:home-b -> ключ B
1. Этот документ — источник истины по деривации секрета и ключей. 1. Этот документ — источник истины по деривации секрета и ключей.
2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа, 2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа,
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь dev-ключ ↔ Solana-адрес — разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь blockchain key ↔ Solana-адрес —
**обязательно** отражать здесь в том же изменении. **обязательно** отражать здесь в том же изменении.
3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма. 3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма.
4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости. 4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости.
+11 -14
View File
@@ -8,9 +8,9 @@
В SHiNE у пользователя есть несколько уровней ключей: В SHiNE у пользователя есть несколько уровней ключей:
- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`). - `root key` - холодный recovery-ключ: при полной ротации дополнительно разрешает замену root + client + blockchain fork. Это не кошелёк.
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя. - `blockchain key` - ключ записи в персональный SHiNE-блокчейн и текущий Solana-wallet/fee payer пользователя.
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей. - `client key` - общий ключ пользовательских устройств для повседневной работы, звонков и DM; Solana-wallet им больше не является.
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере. - `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы. Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
@@ -21,16 +21,13 @@
Назначение: Назначение:
- регистрация пользователя в Solana; - холодное recovery-разрешение для полной смены ключей;
- создание и обновление пользовательской PDA-записи; - подтверждение атомарной ротации `root + client + новый blockchain fork`;
- вызов критически важных Solana-функций; - восстановительные сценарии повышенного уровня доверия.
- изменение главных настроек пользователя;
- управление остальными ключами;
- подтверждение операций, которые должны иметь максимальный уровень доверия.
`root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя. Обычные PDA-update **не требуют root key**: их выполняет активный blockchain key. При полной ротации старый root подписывает тот же hash нового unsigned PDA state, который подписывает новый blockchain key. Так root разрешает переход, не становясь повседневным ключом.
Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `client key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `docs/Keys/DERIVATION.md`, §3. Важно не путать recovery-authority и кошелёк: `root key` не является fee payer. Текущий Solana-wallet/fee payer — активный `blockchain key`. Подробнее — `docs/Keys/DERIVATION.md`, §3.
## `blockchain key` ## `blockchain key`
@@ -40,7 +37,8 @@
- подпись записей в персональном блокчейне пользователя; - подпись записей в персональном блокчейне пользователя;
- подтверждение действий, которые должны попасть в SHiNE-блокчейн; - подтверждение действий, которые должны попасть в SHiNE-блокчейн;
- разделение полномочий между главным Solana-ключом и ключом ежедневной записи. - обычные обновления пользовательской PDA;
- текущий Solana-wallet/fee payer (`base58(active blockchain public key)`).
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи: У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
@@ -59,7 +57,6 @@
- повседневные входящие и исходящие личные сообщения; - повседневные входящие и исходящие личные сообщения;
- звонки и связанные с ними сообщения; - звонки и связанные с ними сообщения;
- self-messages, то есть внутренние сообщения пользователя самому себе; - self-messages, то есть внутренние сообщения пользователя самому себе;
- мелкие Solana-расходы на текущие операции;
- derivation Arweave-кошелька; - derivation Arweave-кошелька;
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу. - оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
@@ -158,7 +155,7 @@ Self-message - это сообщение пользователя самому
## Связанные документы ## Связанные документы
- `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код). - `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`blockchain.key`/`client.key`/`homeserver.key:<имя>`, Solana-wallet, ссылки на код).
- `docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений. - `docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений.
- `docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений. - `docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений.
- `docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна. - `docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
@@ -584,3 +584,28 @@ SYNC_POLL_INTERVAL_SECONDS=300
- server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес. - server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес.
Create/update транзакции больше не реконструируются байт-в-байт из instruction args. После обнаружения изменения sync-модуль перечитывает фактическую текущую PDA через Solana RPC и декодирует её. Это исключает дублирование on-chain сериализации. `close_legacy_pda` не создаёт новое состояние PDA и для runtime-sync не является пользовательским update. Create/update транзакции больше не реконструируются байт-в-байт из instruction args. После обнаружения изменения sync-модуль перечитывает фактическую текущую PDA через Solana RPC и декодирует её. Это исключает дублирование on-chain сериализации. `close_legacy_pda` не создаёт новое состояние PDA и для runtime-sync не является пользовательским update.
---
## Server-local key rotation state (PostgreSQL v26)
Начиная с migration v26 сервер хранит локальное состояние длительной смены ключей отдельно от данных Solana PDA.
В `solana_user_pda_current` добавлены локальные поля:
- `rotation_status` — быстрый текущий статус (`NONE` в обычном режиме);
- `rotation_session_id` — ссылка на текущую запись `key_rotation_sessions`.
Эти поля **не являются частью PDA**, не приходят из Solana и не должны перезаписываться обычным Solana sync upsert-ом.
Подробный прогресс хранится в `key_rotation_sessions`: только публичные old/new root/blockchain/client keys, выбранная точка fork, старый tip, reason/comment, прогресс, ошибка/retry и статусы необязательных wallet/DM этапов. Пароли и приватные ключи в PostgreSQL не сохраняются.
Первая серверная запись создаётся сразу в `COPYING_CHAIN`; состояния `PREPARING` в БД нет. Завершённые `COMPLETE`/`ABORTED` sessions остаются как журнал, а `solana_user_pda_current.rotation_status` возвращается в `NONE`.
### Candidate blocks ротации (PostgreSQL v27)
Начиная с migration v27 будущая ветка во время `COPYING_CHAIN` хранится в отдельной таблице `key_rotation_candidate_blocks`. Она не является частью текущего materialized blockchain state и не должна попадать в обычную `blocks` до финального переключения fork.
Для каждого candidate DataItem сохраняются rotation session, login, candidate blockchain name, block number/hash, полный ANS-104 DataItem, DataItem id и статус публикации. Уникальность `(rotation_session_id, block_number)` запрещает две разные версии одного candidate-блока.
Arweave/Turbo publisher обрабатывает candidate-блоки приоритетно. `key_rotation_sessions.progress_current` отражает число DataItem, уже реально опубликованных publisher-ом, а не число принятых API-сервером.
@@ -16,7 +16,7 @@
Основные данные: Основные данные:
- `RootKeyBlock` — cold recovery authority; - `RootKeyBlock` — cold recovery authority;
- `ClientKeyBlock` — клиентский/кошелёчный ключ; - `ClientKeyBlock` — клиентский ключ для DM/устройств; Solana-wallet/fee payer — активный blockchain key;
- `BlockchainRegistryBlock` — append-only список fork: `blockchain_key[32] + created_at_ms:u64 + paid_limit_bytes:u32`; последний fork активен; - `BlockchainRegistryBlock` — append-only список fork: `blockchain_key[32] + created_at_ms:u64 + paid_limit_bytes:u32`; последний fork активен;
- необязательный `ServerProfileBlock` — в 1.2 ровно один адрес сервера; - необязательный `ServerProfileBlock` — в 1.2 ровно один адрес сервера;
- необязательный `AccessServersBlock` — в 1.2 максимум один access server. - необязательный `AccessServersBlock` — в 1.2 максимум один access server.
@@ -90,14 +90,14 @@
## Кто оплачивает create/update user_pda ## Кто оплачивает create/update user_pda
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`. - И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с активного `blockchainKey`.
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя или сервера. - В UI это означает, что Solana fee payer всегда берётся из текущего активного `blockchain key` пользователя или сервера.
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию. - `rootKey` не оплачивает транзакцию; он дополнительно авторизует только полную ротацию ключей.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`. - Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес текущего `blockchainKey`.
## Важно ## Важно
- `init_users_economy_config` выполняется один раз на программу. Если PDA уже создан, повторный вызов вернёт ошибку `already initialized`. - `init_users_economy_config` выполняется один раз на программу. Если PDA уже создан, повторный вызов вернёт ошибку `already initialized`.
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: транзакцию оплачивает `clientKey`, а содержимое записи подписывает `rootKey`. - Серверные приватные ключи для Solana не используются как отдельный backend-wallet: транзакцию оплачивает `blockchainKey`. Обычное состояние PDA подписывает blockchain key; root дополнительно участвует только в полной ротации ключей.
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина. - `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
- При новом devnet deploy планируется использовать те же program keypair, чтобы `program id` на devnet совпадали с mainnet. - При новом devnet deploy планируется использовать те же program keypair, чтобы `program id` на devnet совпадали с mainnet.
+21
View File
@@ -16,6 +16,7 @@ import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'
import { initPwaPush } from './services/pwa-push-service.js'; import { initPwaPush } from './services/pwa-push-service.js';
import { initCallUiOverlay } from './services/call-ui-service.js'; import { initCallUiOverlay } from './services/call-ui-service.js';
import { showToast } from './services/channels-ux.js'; import { showToast } from './services/channels-ux.js';
import { KeyRotationClient } from './services/key-rotation-service.js';
import { import {
handleCallPushAction, handleCallPushAction,
handleIncomingCallInvite, handleIncomingCallInvite,
@@ -73,6 +74,8 @@ import * as profileEditView from './pages/profile-edit-view.js';
import * as profilesView from './pages/profiles-view.js'; import * as profilesView from './pages/profiles-view.js';
import * as walletView from './pages/wallet-view.js?v=202609260900'; import * as walletView from './pages/wallet-view.js?v=202609260900';
import * as settingsView from './pages/settings-view.js'; import * as settingsView from './pages/settings-view.js';
import * as myBlockchainView from './pages/my-blockchain-view.js';
import * as keyRotationView from './pages/key-rotation-view.js';
import * as accessServersView from './pages/access-servers-view.js'; import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js';
import * as advancedSettingsView from './pages/advanced-settings-view.js'; import * as advancedSettingsView from './pages/advanced-settings-view.js';
@@ -142,6 +145,8 @@ const routes = {
'profiles-view': profilesView, 'profiles-view': profilesView,
'wallet-view': walletView, 'wallet-view': walletView,
'settings-view': settingsView, 'settings-view': settingsView,
'my-blockchain-view': myBlockchainView,
'key-rotation-view': keyRotationView,
'access-servers-view': accessServersView, 'access-servers-view': accessServersView,
'developer-settings-view': developerSettingsView, 'developer-settings-view': developerSettingsView,
'advanced-settings-view': advancedSettingsView, 'advanced-settings-view': advancedSettingsView,
@@ -1376,6 +1381,21 @@ function attachPageScrollToBottom(pageId, screen) {
}); });
} }
async function redirectToActiveKeyRotationIfNeeded() {
if (!state.session.isAuthorized || state.session.isLocalDemo) return false;
try {
const rotation = await new KeyRotationClient(authService).status();
const status = String(rotation?.rotationStatus || 'NONE');
if (status !== 'NONE' && getRoute().pageId !== 'key-rotation-view') {
navigate('key-rotation-view');
return true;
}
} catch (error) {
console.warn('[key-rotation] status check failed', error);
}
return false;
}
function renderApp() { function renderApp() {
syncTrackedRouteHistory(window.location.pathname || '/'); syncTrackedRouteHistory(window.location.pathname || '/');
const route = getRoute(); const route = getRoute();
@@ -1566,6 +1586,7 @@ async function init() {
setSessionAuthorizedHandler(() => { setSessionAuthorizedHandler(() => {
void ensureSessionRuntimeStarted(); void ensureSessionRuntimeStarted();
void processPendingCallPushActionIfPossible(); void processPendingCallPushActionIfPossible();
void redirectToActiveKeyRotationIfNeeded();
}); });
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
@@ -290,7 +290,7 @@ export function openArweaveAttachmentManager({
mode = 'attachment', mode = 'attachment',
historyPurpose = '', historyPurpose = '',
uploadTransport = 'turbo', uploadTransport = 'turbo',
turboKeySource = 'client', turboKeySource = 'blockchain',
dialogTitle = '', dialogTitle = '',
uploadButtonLabel = '', uploadButtonLabel = '',
initialFile = null, initialFile = null,
@@ -406,20 +406,9 @@ export function openArweaveAttachmentManager({
turboKeyChoices = await getStoredSolanaWalletChoices({ turboKeyChoices = await getStoredSolanaWalletChoices({
login: cleanLogin, login: cleanLogin,
storagePwd: cleanStoragePwd, storagePwd: cleanStoragePwd,
includeRoot: true,
});
turboKeyChoices.sort((a, b) => {
if (a?.keySource === b?.keySource) return 0;
if (a?.keySource === 'client') return -1;
if (b?.keySource === 'client') return 1;
return 0;
}); });
if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) { if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) {
selectedTurboKeySource = String( selectedTurboKeySource = String(turboKeyChoices[0]?.keySource || 'blockchain');
turboKeyChoices.find((item) => item.keySource === 'client')?.keySource
|| turboKeyChoices[0]?.keySource
|| 'client'
);
} }
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource); writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
} catch (error) { } catch (error) {
+22 -21
View File
@@ -177,14 +177,14 @@ function createPasswordModal() {
<div class="stack" style="gap:0.45rem;"> <div class="stack" style="gap:0.45rem;">
<label class="checkbox-row"> <label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="once" checked /> <input type="radio" name="access-servers-key-mode" value="once" checked />
<span>Использовать главный ключ только сейчас</span> <span>Использовать blockchain key только сейчас</span>
</label> </label>
<label class="checkbox-row"> <label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="save" /> <input type="radio" name="access-servers-key-mode" value="save" />
<span>Сохранить главный ключ на этом устройстве</span> <span>Сохранить blockchain key на этом устройстве</span>
</label> </label>
</div> </div>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p> <p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, blockchain key попадёт в зашифрованный контейнер устройства.</p>
<div class="form-actions-grid"> <div class="form-actions-grid">
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button> <button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button>
<button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button> <button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button>
@@ -247,7 +247,7 @@ function createPasswordModal() {
const mode = root.querySelector('input[name="access-servers-key-mode"]:checked'); const mode = root.querySelector('input[name="access-servers-key-mode"]:checked');
close({ close({
password, password,
saveRoot: mode instanceof HTMLInputElement && mode.value === 'save', saveBlockchain: mode instanceof HTMLInputElement && mode.value === 'save',
}); });
}); });
window.setTimeout(() => inputEl.focus(), 0); window.setTimeout(() => inputEl.focus(), 0);
@@ -482,48 +482,49 @@ export function render({navigate, chrome}) {
saved = null; saved = null;
} }
const savedRoot = String(saved?.rootKey || '').trim(); const savedBlockchain = String(saved?.blockchainKey || '').trim();
const savedClient = String(saved?.clientKey || '').trim(); const savedClient = String(saved?.clientKey || '').trim();
if (savedRoot && savedClient) { if (savedBlockchain && savedClient) {
return { return {
rootPrivatePkcs8B64: savedRoot, blockchainPrivatePkcs8B64: savedBlockchain,
clientPrivatePkcs8B64: savedClient, clientPrivatePkcs8B64: savedClient,
clientAddress: await clientAddressFromPrivatePkcs8(savedClient), payerAddress: await clientAddressFromPrivatePkcs8(savedBlockchain),
}; };
} }
const passwordResult = await passwordModal?.open({ const passwordResult = await passwordModal?.open({
title: 'Нужен пароль для обновления серверов доступа', title: 'Нужен пароль для обновления серверов доступа',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.', text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
note: savedClient note: savedClient
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.' ? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен blockchain key.'
: 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.', : 'На устройстве не хватает blockchain key и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
}); });
if (!passwordResult) { if (!passwordResult) {
throw new Error('Операция отменена пользователем.'); throw new Error('Операция отменена пользователем.');
} }
const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password); const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password);
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64); const derivedBlockchainPublic = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64); const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) { const activeBlockchain = currentPda.forks?.at(-1)?.blockchainKey;
throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.'); if (!activeBlockchain || !equalBytes(derivedBlockchainPublic, activeBlockchain)) {
throw new Error('Пароль не подходит: blockchain key не совпал с активным fork аккаунта.');
} }
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) { if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.'); throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
} }
if (passwordResult.saveRoot) { if (passwordResult.saveBlockchain) {
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, { await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
saveRoot: true, saveRoot: false,
saveBlockchain: false, saveBlockchain: true,
}); });
} }
return { return {
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64, blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64), payerAddress: clientAddressFromPublicB64(keyBundle.blockchainPair.publicKeyB64),
}; };
}; };
@@ -550,7 +551,7 @@ export function render({navigate, chrome}) {
const tx = await updateShineUserPdaOnSolana({ const tx = await updateShineUserPdaOnSolana({
login: sessionLogin, login: sessionLogin,
solanaEndpoint, solanaEndpoint,
rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64, blockchainPrivatePkcs8B64: signingMaterial.blockchainPrivatePkcs8B64,
clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64, clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64,
accessServers: normalizedList, accessServers: normalizedList,
}); });
@@ -562,7 +563,7 @@ export function render({navigate, chrome}) {
refreshAddButton(); refreshAddButton();
} catch (error) { } catch (error) {
if (isInsufficientFundsForRentError(error)) { if (isInsufficientFundsForRentError(error)) {
showTopupRequiredStatus(target, signingMaterial?.clientAddress); showTopupRequiredStatus(target, signingMaterial?.payerAddress);
} else { } else {
target.textContent = error?.message || 'Не удалось обновить сервер доступа.'; target.textContent = error?.message || 'Не удалось обновить сервер доступа.';
} }
+4 -27
View File
@@ -14,7 +14,7 @@ import {
formatSol, formatSol,
getBalanceSol, getBalanceSol,
getSolanaWalletFromStoredSecret, getSolanaWalletFromStoredSecret,
getWalletFromStoredClientKey, getWalletFromStoredBlockchainKey,
getWalletFromStoredRootKey, getWalletFromStoredRootKey,
solanaAddressFromPublicKeyBase64, solanaAddressFromPublicKeyBase64,
transferSol, transferSol,
@@ -138,9 +138,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
if (!root) return; if (!root) return;
const recipientChoices = [ const recipientChoices = [
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'), publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
].filter(Boolean); ].filter(Boolean);
root.innerHTML = ` root.innerHTML = `
@@ -166,10 +164,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
<div class="channel-support-address" id="channel-support-recipient-address">—</div> <div class="channel-support-address" id="channel-support-recipient-address">—</div>
<label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label> <label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label>
<select class="select" id="channel-support-sender-key"> <div class="channel-support-address" id="channel-support-sender-key">Blockchain key</div>
<option value="client-key">Client key</option>
<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-address">—</div>
<div class="channel-support-balance-row"> <div class="channel-support-balance-row">
@@ -247,31 +242,13 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
}; };
const resolveSenderWallet = async () => { const resolveSenderWallet = async () => {
const keyId = String(senderSelect?.value || 'client-key'); const keyId = 'blockchain-key';
if (walletCache.has(keyId)) return walletCache.get(keyId); if (walletCache.has(keyId)) return walletCache.get(keyId);
const login = String(state.session.login || '').trim(); const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim(); const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.'); if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
let wallet;
if (keyId === 'root-key') {
try {
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
} catch {
const password = window.prompt(
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
'',
);
if (password == null) throw new Error('Операция отменена.');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
}
} else {
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
}
walletCache.set(keyId, wallet); walletCache.set(keyId, wallet);
return wallet; return wallet;
+4 -27
View File
@@ -6,7 +6,7 @@ import {
formatSol, formatSol,
getBalanceSol, getBalanceSol,
getSolanaWalletFromStoredSecret, getSolanaWalletFromStoredSecret,
getWalletFromStoredClientKey, getWalletFromStoredBlockchainKey,
getWalletFromStoredRootKey, getWalletFromStoredRootKey,
solanaAddressFromPublicKeyBase64, solanaAddressFromPublicKeyBase64,
transferSol, transferSol,
@@ -140,9 +140,7 @@ export function render({ navigate, route, chrome }) {
const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim(); const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
const channelName = String(channel?.channelName || selector?.channelName || '').trim(); const channelName = String(channel?.channelName || selector?.channelName || '').trim();
const recipientChoices = [ const recipientChoices = [
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'), publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
].filter(Boolean); ].filter(Boolean);
content.innerHTML = ` content.innerHTML = `
@@ -169,10 +167,7 @@ export function render({ navigate, route, chrome }) {
<section class="channel-donate-section"> <section class="channel-donate-section">
<label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label> <label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label>
<select class="select" id="channel-donate-sender-key"> <div class="channel-donate-address" id="channel-donate-sender-key">Blockchain key</div>
<option value="client-key">Client key</option>
<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-address">—</div>
<div class="channel-donate-balance-row"> <div class="channel-donate-balance-row">
@@ -233,31 +228,13 @@ export function render({ navigate, route, chrome }) {
}; };
const resolveSenderWallet = async () => { const resolveSenderWallet = async () => {
const keyId = String(senderSelect?.value || 'client-key'); const keyId = 'blockchain-key';
if (walletCache.has(keyId)) return walletCache.get(keyId); if (walletCache.has(keyId)) return walletCache.get(keyId);
const login = String(state.session.login || '').trim(); const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim(); const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.'); if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
let wallet;
if (keyId === 'root-key') {
try {
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
} catch {
const password = window.prompt(
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
'',
);
if (password == null) throw new Error('Операция отменена.');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
}
} else {
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
}
walletCache.set(keyId, wallet); walletCache.set(keyId, wallet);
return wallet; return wallet;
+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;
}
+49
View File
@@ -0,0 +1,49 @@
import { createTopBar } from '../components/topbar.js';
import { authService } from '../state.js';
import { KeyRotationClient } from '../services/key-rotation-service.js';
export const pageMeta = { id: 'my-blockchain-view', title: 'Мой блокчейн' };
const TYPE_LABELS = new Map([
['0:0','Начало блокчейна'], ['0:2','Смена ключей / fork'],
['1:1','Публикация'], ['1:2','Ответ'], ['1:3','Редактирование'],
['2:1','Реакция'], ['3:1','Связь'], ['4:1','Статус'],
]);
function shortHash(v){const s=String(v||'');return s.length>18?`${s.slice(0,9)}…${s.slice(-7)}`:s;}
function eventLabel(item){return TYPE_LABELS.get(`${item.msgType}:${item.msgSubType}`)||`Событие ${item.msgType}/${item.msgSubType}`;}
function formatDate(ms){try{return new Date(Number(ms)||0).toLocaleString('ru-RU');}catch{return '';}}
export function render({ navigate, chrome }) {
const screen=document.createElement('section'); screen.className='stack';
chrome?.setTopbar(createTopBar({ title:'Мой блокчейн', back:{label:'←',onClick:()=>navigate('settings-view')} }));
const intro=document.createElement('div'); intro.className='card stack';
intro.innerHTML=`<strong>История ваших действий</strong><span class="meta-muted">Это текущая активная версия вашего блокчейна. Хэши записей сохраняют логическую идентичность при fork.</span>`;
const rotate=document.createElement('button'); rotate.type='button'; rotate.className='primary-btn'; rotate.textContent='Сменить пароль / ключи'; rotate.addEventListener('click',()=>navigate('key-rotation-view'));
intro.append(rotate); screen.append(intro);
const list=document.createElement('div'); list.className='stack'; screen.append(list);
const more=document.createElement('button'); more.type='button'; more.className='secondary-btn'; more.textContent='Показать более ранние записи'; more.hidden=true; screen.append(more);
const client=new KeyRotationClient(authService); let before=null; let loading=false; let disposed=false;
async function load(reset=false){
if(loading||disposed)return; loading=true; more.disabled=true;
if(reset){list.innerHTML='';before=null;}
try{
const data=await client.getMyBlockchain({beforeBlock:before,limit:50,includeBlockBytes:false});
if(reset){const head=document.createElement('div');head.className='card stack';head.innerHTML=`<strong>${data.blockchainName||'—'}</strong><span class="meta-muted">Последний блок: #${Number(data.tipBlockNumber??-1)} · ${shortHash(data.tipBlockHash)}</span>`;list.append(head);}
for(const item of data.blocks||[]){
const card=document.createElement('div');card.className='card stack';
const target=item.toLogin?`<span class="meta-muted">Цель: ${escapeHtml(item.toLogin)} #${item.toBlockNumber??'—'} · ${shortHash(item.toBlockHash)}</span>`:'';
card.innerHTML=`<div><strong>#${item.blockNumber} · ${escapeHtml(eventLabel(item))}</strong></div><span class="meta-muted">${formatDate(item.timestampMs)} · hash ${shortHash(item.blockHash)}</span>${target}`;
list.append(card);
}
before=data.nextBeforeBlock;
more.hidden=before==null;
if(!(data.blocks||[]).length && reset){const empty=document.createElement('div');empty.className='card';empty.textContent='Записей пока нет.';list.append(empty);}
}catch(e){const err=document.createElement('div');err.className='card';err.textContent=`Не удалось загрузить блокчейн: ${e?.message||e}`;list.append(err);}
finally{loading=false;more.disabled=false;}
}
more.addEventListener('click',()=>void load(false)); void load(true);
screen.cleanup=()=>{disposed=true;}; return screen;
}
function escapeHtml(value){return String(value??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
@@ -126,7 +126,7 @@ export function render({ navigate }) {
// Blockchain key // Blockchain key
const bchSep = document.createElement('p'); const bchSep = document.createElement('p');
bchSep.className = 'field-label'; bchSep.className = 'field-label';
bchSep.textContent = 'Blockchain key'; bchSep.textContent = 'Blockchain key (= Solana wallet)';
card.append(bchSep); card.append(bchSep);
card.append(makePublicField({ card.append(makePublicField({
label: 'Blockchain — публичный (base58)', label: 'Blockchain — публичный (base58)',
@@ -140,7 +140,7 @@ export function render({ navigate }) {
// Client key // Client key
const devSep = document.createElement('p'); const devSep = document.createElement('p');
devSep.className = 'field-label'; devSep.className = 'field-label';
devSep.textContent = 'Client key (= Solana wallet)'; devSep.textContent = 'Client key';
card.append(devSep); card.append(devSep);
card.append(makePublicField({ card.append(makePublicField({
label: 'Client — публичный (base58)', label: 'Client — публичный (base58)',
@@ -234,7 +234,7 @@ export function render({ navigate }) {
const deriveUserWalletAddress = async () => { const deriveUserWalletAddress = async () => {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle; const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle) throw new Error('Ключи ещё не сгенерированы. Вернитесь на предыдущий шаг.'); if (!keyBundle) throw new Error('Ключи ещё не сгенерированы. Вернитесь на предыдущий шаг.');
const { publicKeyB64 } = keyBundle.clientPair; const { publicKeyB64 } = keyBundle.blockchainPair;
const raw = atob(publicKeyB64); const raw = atob(publicKeyB64);
const bytes = new Uint8Array(raw.length); const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
@@ -387,7 +387,7 @@ export function render({ navigate }) {
await refreshBalance({ addressOverride: walletAddress }); await refreshBalance({ addressOverride: walletAddress });
} catch (error) { } catch (error) {
status.className = 'status-line is-unavailable'; status.className = 'status-line is-unavailable';
status.textContent = `Не удалось подготовить client.key: ${error?.message || 'unknown'}`; status.textContent = `Не удалось подготовить blockchain key: ${error?.message || 'unknown'}`;
status.style.display = ''; status.style.display = '';
} }
})(); })();
+2
View File
@@ -73,6 +73,7 @@ export function render({navigate, chrome}) {
</div> </div>
<div class="nav-list"> <div class="nav-list">
${row('settings-device', 'Устройства', 'Сеансы, подключение устройств, ключи')} ${row('settings-device', 'Устройства', 'Сеансы, подключение устройств, ключи')}
${row('settings-my-blockchain', 'Мой блокчейн', 'Все ваши подписанные события и смена ключей')}
${row('settings-access-servers', 'Сервер доступа', 'Личная переписка, звонки и зашифрованные данные')} ${row('settings-access-servers', 'Сервер доступа', 'Личная переписка, звонки и зашифрованные данные')}
${row('settings-blockchain-servers', 'Серверы блокчейнов', 'Solana, Сияние и Arweave для публичных данных')} ${row('settings-blockchain-servers', 'Серверы блокчейнов', 'Solana, Сияние и Arweave для публичных данных')}
${row('settings-arweave-uploads', 'Файлы в блокчейне', 'Заранее загрузить файл и выбрать его потом')} ${row('settings-arweave-uploads', 'Файлы в блокчейне', 'Заранее загрузить файл и выбрать его потом')}
@@ -104,6 +105,7 @@ export function render({navigate, chrome}) {
}); });
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view')); card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-my-blockchain').addEventListener('click', () => navigate('my-blockchain-view'));
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view')); card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view')); card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view')); card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
+7 -8
View File
@@ -14,15 +14,14 @@ const DEVNET_ENDPOINT = 'https://api.devnet.solana.com';
const SENDER_PRIVATE_32_BASE58 = '6xqAuKYvA8qrCdAkcw7Y8aMgvBnYk8JLxWLma5BzbAvu'; const SENDER_PRIVATE_32_BASE58 = '6xqAuKYvA8qrCdAkcw7Y8aMgvBnYk8JLxWLma5BzbAvu';
const REGISTRATION_TOPUP_AMOUNT_SOL = 0.02; const REGISTRATION_TOPUP_AMOUNT_SOL = 0.02;
// Канонический Solana-адрес пополнения = публичный device-ключ из сгенерированного набора ключей. // Канонический Solana-кошелёк пользователя = текущий blockchain key.
// Тот же путь, что в registration-payment-view (deriveUserWalletAddress); не выводим адрес // Тот же адрес используется как fee payer для регистрации и обычных PDA update.
// напрямую из пароля, иначе он расходится с device-ключом регистрации. async function blockchainWalletAddressFromBundle() {
async function clientWalletAddressFromBundle() {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle; const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle || !keyBundle.clientPair) { if (!keyBundle || !keyBundle.blockchainPair) {
throw new Error('Ключи ещё не сгенерированы. Вернитесь на экран регистрации.'); throw new Error('Ключи ещё не сгенерированы. Вернитесь на экран регистрации.');
} }
const raw = atob(keyBundle.clientPair.publicKeyB64); const raw = atob(keyBundle.blockchainPair.publicKeyB64);
const bytes = new Uint8Array(raw.length); const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i); for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
const { PublicKey } = await loadSolanaWeb3(); const { PublicKey } = await loadSolanaWeb3();
@@ -73,7 +72,7 @@ export function render({ navigate }) {
card.innerHTML = ` card.innerHTML = `
<p class="auth-copy">Можете или пополнить счёт тестовыми соланами и продолжить регистрацию.</p> <p class="auth-copy">Можете или пополнить счёт тестовыми соланами и продолжить регистрацию.</p>
<div class="card stack" style="padding:12px; max-width:320px;"> <div class="card stack" style="padding:12px; max-width:320px;">
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (client key = Solana wallet)</div> <div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (blockchain key = Solana wallet)</div>
</div> </div>
<div class="stack" style="gap:6px;"> <div class="stack" style="gap:6px;">
<p class="meta-muted">Или можете отдельно открыть страницу тестового пополнения.</p> <p class="meta-muted">Или можете отдельно открыть страницу тестового пополнения.</p>
@@ -139,7 +138,7 @@ export function render({ navigate }) {
(async () => { (async () => {
try { try {
if (!walletValue.value) { if (!walletValue.value) {
const address = await clientWalletAddressFromBundle(); const address = await blockchainWalletAddressFromBundle();
state.registrationPayment.walletAddress = address; state.registrationPayment.walletAddress = address;
walletValue.value = address; walletValue.value = address;
} }
+10 -10
View File
@@ -5,7 +5,7 @@ import {
formatSol, formatSol,
getBalanceSol, getBalanceSol,
getTopupSiteUrl, getTopupSiteUrl,
getWalletFromStoredClientKey, getWalletFromStoredBlockchainKey,
transferSol, transferSol,
} from '../services/solana-wallet-service.js'; } from '../services/solana-wallet-service.js';
import { import {
@@ -983,7 +983,7 @@ export function render({navigate, chrome}) {
<h2 style="margin:0;">Купить билет на сумму в долларах</h2> <h2 style="margin:0;">Купить билет на сумму в долларах</h2>
<p class="meta-muted" style="margin:0; line-height:1.55;"> <p class="meta-muted" style="margin:0; line-height:1.55;">
Здесь вводится сумма покупки и адрес получателя. Если адрес не указан, можно купить на тот же кошелёк, с которого идет оплата. Здесь вводится сумма покупки и адрес получателя. Если адрес не указан, можно купить на тот же кошелёк, с которого идет оплата.
Покупка подписывается вашим client key и отклоняется, если курс уходит дальше допустимого порога. Оплата выполняется с текущего blockchain key. Технические подписи протокола покупки остаются отдельной частью операции.
</p> </p>
`; `;
@@ -1169,7 +1169,7 @@ export function render({navigate, chrome}) {
currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer); currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer);
} }
if (!walletCtx) { if (!walletCtx) {
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow()); walletCtx = await getWalletFromStoredBlockchainKey(sessionArgsOrThrow());
walletAddress = walletCtx.address; walletAddress = walletCtx.address;
} }
setRecipientFromWallet(); setRecipientFromWallet();
@@ -1265,7 +1265,7 @@ export function render({navigate, chrome}) {
if (!walletCtx) { if (!walletCtx) {
try { try {
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow()); walletCtx = await getWalletFromStoredBlockchainKey(sessionArgsOrThrow());
walletAddress = walletCtx.address; walletAddress = walletCtx.address;
if (modeToken !== activeModeToken) return; if (modeToken !== activeModeToken) return;
setRecipientFromWallet(); setRecipientFromWallet();
@@ -1867,7 +1867,7 @@ export function render({navigate, chrome}) {
addressCard.className = 'card'; addressCard.className = 'card';
addressCard.style.padding = '10px'; addressCard.style.padding = '10px';
addressCard.innerHTML = ` addressCard.innerHTML = `
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (client.key)</p> <p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (blockchain.key)</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">—</p> <p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">—</p>
`; `;
const addressEl = addressCard.querySelector('#wallet-address-value'); const addressEl = addressCard.querySelector('#wallet-address-value');
@@ -1932,7 +1932,7 @@ export function render({navigate, chrome}) {
sendBtn.addEventListener('click', async () => { sendBtn.addEventListener('click', async () => {
if (!walletCtx?.keypair) { if (!walletCtx?.keypair) {
setStatus('Перевод недоступен: client.key не загружен.'); setStatus('Перевод недоступен: blockchain.key не загружен.');
return; return;
} }
const toAddress = window.prompt('Введите адрес получателя (Solana):', ''); const toAddress = window.prompt('Введите адрес получателя (Solana):', '');
@@ -1968,17 +1968,17 @@ export function render({navigate, chrome}) {
}); });
content.append(backBtn, card, actions); content.append(backBtn, card, actions);
setStatus('Инициализация client.key...'); setStatus('Инициализация blockchain.key...');
try { try {
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow()); walletCtx = await getWalletFromStoredBlockchainKey(sessionArgsOrThrow());
if (modeToken !== activeModeToken) return; if (modeToken !== activeModeToken) return;
walletAddress = walletCtx.address; walletAddress = walletCtx.address;
addressEl.textContent = walletAddress; addressEl.textContent = walletAddress;
await refreshBalance(); await refreshBalance();
} catch (error) { } catch (error) {
if (modeToken !== activeModeToken) return; if (modeToken !== activeModeToken) return;
addressEl.textContent = 'client.key недоступен'; addressEl.textContent = 'blockchain.key недоступен';
setStatus(`Не удалось инициализировать кошелёк: ${error?.message || 'unknown'}`); setStatus(`Не удалось инициализировать кошелёк: ${error?.message || 'unknown'}`);
} }
} }
@@ -2146,7 +2146,7 @@ export function render({navigate, chrome}) {
await refreshBalance(); await refreshBalance();
} catch (error) { } catch (error) {
if (modeToken !== activeModeToken) return; if (modeToken !== activeModeToken) return;
addressEl.textContent = 'client.key недоступен'; addressEl.textContent = 'blockchain.key недоступен';
clearArweaveSecretsInMemory(); clearArweaveSecretsInMemory();
setStatus(`Не удалось инициализировать Arweave-кошелёк: ${error?.message || 'unknown'}`); setStatus(`Не удалось инициализировать Arweave-кошелёк: ${error?.message || 'unknown'}`);
} }
+6
View File
@@ -33,6 +33,8 @@ const PRETTY_PATHS = new Map([
['network-view', 'network'], ['network-view', 'network'],
['notifications-view', 'notifications'], ['notifications-view', 'notifications'],
['settings-view', 'settings'], ['settings-view', 'settings'],
['my-blockchain-view', 'settings/blockchain'],
['key-rotation-view', 'settings/key-rotation'],
['access-servers-view', 'settings/access-servers'], ['access-servers-view', 'settings/access-servers'],
['server-settings-view', 'settings/servers'], ['server-settings-view', 'settings/servers'],
['arweave-uploads-view', 'settings/arweave-uploads'], ['arweave-uploads-view', 'settings/arweave-uploads'],
@@ -324,6 +326,8 @@ export function parseRouteFromPath(pathname = '') {
if (pageId === 'settings') { if (pageId === 'settings') {
const sub = decodePart(segments[1] || '').toLowerCase(); const sub = decodePart(segments[1] || '').toLowerCase();
if (sub === 'access-servers') return { pageId: 'access-servers-view', params: {} }; if (sub === 'access-servers') return { pageId: 'access-servers-view', params: {} };
if (sub === 'blockchain') return { pageId: 'my-blockchain-view', params: {} };
if (sub === 'key-rotation') return { pageId: 'key-rotation-view', params: {} };
if (sub === 'servers') return { pageId: 'server-settings-view', params: {} }; if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} }; if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} };
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} }; if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
@@ -499,6 +503,8 @@ function resolveToolbarTab(pageId) {
pageId === 'profiles-view' || pageId === 'profiles-view' ||
pageId === 'wallet-view' || pageId === 'wallet-view' ||
pageId === 'settings-view' || pageId === 'settings-view' ||
pageId === 'my-blockchain-view' ||
pageId === 'key-rotation-view' ||
pageId === 'access-servers-view' || pageId === 'access-servers-view' ||
pageId === 'developer-settings-view' || pageId === 'developer-settings-view' ||
pageId === 'advanced-settings-view' || pageId === 'advanced-settings-view' ||
+78
View File
@@ -123,3 +123,81 @@ export async function createAns104DataItem({ owner32, privateKey, data, tags = [
data, data,
); );
} }
function readUint64LE(bytes, offset) {
if (!(bytes instanceof Uint8Array) || offset < 0 || offset + 8 > bytes.length) throw new Error('ANS-104 truncated uint64');
let value = 0n;
for (let i = 7; i >= 0; i -= 1) value = (value << 8n) | BigInt(bytes[offset + i]);
if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('ANS-104 uint64 exceeds JS safe integer');
return Number(value);
}
/** Parse enough of an Ed25519 ANS-104 DataItem to re-sign its exact SHiNE payload. */
export function parseAns104DataItem(rawBytes) {
const raw = rawBytes instanceof Uint8Array ? rawBytes : Uint8Array.from(rawBytes || []);
let p = 0;
if (raw.length < 2 + 64 + 32 + 1 + 1 + 8 + 8) throw new Error('ANS-104 DataItem is too short');
const sigType = new DataView(raw.buffer, raw.byteOffset, raw.byteLength).getUint16(p, true); p += 2;
if (sigType !== ANS104_ED25519_SIGNATURE_TYPE) throw new Error(`Unsupported ANS-104 signature type: ${sigType}`);
const signature = raw.slice(p, p + 64); p += 64;
const owner32 = raw.slice(p, p + 32); p += 32;
const targetPresent = raw[p++] !== 0;
const target = targetPresent ? raw.slice(p, p + 32) : new Uint8Array(0);
if (targetPresent) p += 32;
const anchorPresent = raw[p++] !== 0;
const anchor = anchorPresent ? raw.slice(p, p + 32) : new Uint8Array(0);
if (anchorPresent) p += 32;
const tagsCount = readUint64LE(raw, p); p += 8;
const tagsBytesLength = readUint64LE(raw, p); p += 8;
if (p + tagsBytesLength > raw.length) throw new Error('ANS-104 tags are truncated');
const rawTags = raw.slice(p, p + tagsBytesLength); p += tagsBytesLength;
const data = raw.slice(p);
return { sigType, signature, owner32, target, anchor, tagsCount, rawTags, data, raw };
}
async function ans104SigningMessageRaw({ owner32, target = new Uint8Array(0), anchor = new Uint8Array(0), rawTags, data }) {
if (!(owner32 instanceof Uint8Array) || owner32.length !== 32) throw new Error('ANS-104 owner must be 32 bytes');
if (!(target instanceof Uint8Array) || (target.length !== 0 && target.length !== 32)) throw new Error('ANS-104 target must be empty or 32 bytes');
if (!(anchor instanceof Uint8Array) || (anchor.length !== 0 && anchor.length !== 32)) throw new Error('ANS-104 anchor must be empty or 32 bytes');
if (!(rawTags instanceof Uint8Array)) throw new Error('ANS-104 rawTags must be Uint8Array');
if (!(data instanceof Uint8Array)) throw new Error('ANS-104 data must be Uint8Array');
return deepHash([
TE.encode('dataitem'),
TE.encode('1'),
TE.encode(String(ANS104_ED25519_SIGNATURE_TYPE)),
owner32,
target,
anchor,
rawTags,
data,
]);
}
/**
* Re-sign a DataItem while preserving exact raw tags/target/anchor/data bytes.
* Used by key rotation so SHA-256(SHiNE Frame) is unchanged across forks.
*/
export async function createAns104DataItemWithRawParts({
owner32,
privateKey,
data,
rawTags,
tagsCount,
target = new Uint8Array(0),
anchor = new Uint8Array(0),
}) {
const signingMessage = await ans104SigningMessageRaw({ owner32, target, anchor, rawTags, data });
const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, signingMessage));
if (signature.length !== 64) throw new Error(`Unexpected Ed25519 signature length: ${signature.length}`);
return concatBytes(
uint16LE(ANS104_ED25519_SIGNATURE_TYPE),
signature,
owner32,
Uint8Array.of(target.length ? 1 : 0), target,
Uint8Array.of(anchor.length ? 1 : 0), anchor,
uint64LE(tagsCount),
uint64LE(rawTags.length),
rawTags,
data,
);
}
+90 -64
View File
@@ -553,31 +553,31 @@ function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thi
); );
} }
function makeReactionLikeBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex }) { function makeReactionLikeBodyBytes({ toLogin, toBlockNumber, toBlockHashHex }) {
const cleanBch = String(toBlockchainName || '').trim(); const cleanLogin = String(toLogin || '').trim();
if (!cleanBch) throw new Error('toBlockchainName is required for like'); if (!cleanLogin) throw new Error('toLogin is required for like');
const blockNumber = Number(toBlockNumber); const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) { if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for like'); throw new Error('Invalid toBlockNumber for like');
} }
const bchBytes = utf8Bytes(cleanBch); const loginBytes = utf8Bytes(cleanLogin);
if (bchBytes.length < 1 || bchBytes.length > 255) { if (loginBytes.length < 1 || loginBytes.length > 255) {
throw new Error('toBlockchainName must be 1..255 bytes'); throw new Error('toLogin must be 1..255 bytes');
} }
return concatBytes( return concatBytes(
int8Byte(bchBytes.length), int8Byte(loginBytes.length),
bchBytes, loginBytes,
int32Bytes(blockNumber), int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)) hexToBytes(normalizeHex32(toBlockHashHex))
); );
} }
function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text }) { function makeTextReplyBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text }) {
const cleanBch = String(toBlockchainName || '').trim(); const cleanLogin = String(toLogin || '').trim();
if (!cleanBch) throw new Error('toBlockchainName is required for reply'); if (!cleanLogin) throw new Error('toLogin is required for reply');
const blockNumber = Number(toBlockNumber); const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) { if (!Number.isFinite(blockNumber) || blockNumber < 0) {
@@ -587,9 +587,9 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
const message = String(text || '').trim(); const message = String(text || '').trim();
if (!message) throw new Error('Reply text is required'); if (!message) throw new Error('Reply text is required');
const bchBytes = utf8Bytes(cleanBch); const loginBytes = utf8Bytes(cleanLogin);
if (bchBytes.length < 1 || bchBytes.length > 255) { if (loginBytes.length < 1 || loginBytes.length > 255) {
throw new Error('toBlockchainName must be 1..255 bytes'); throw new Error('toLogin must be 1..255 bytes');
} }
const textBytes = utf8Bytes(message); const textBytes = utf8Bytes(message);
@@ -598,8 +598,8 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
} }
return concatBytes( return concatBytes(
int8Byte(bchBytes.length), int8Byte(loginBytes.length),
bchBytes, loginBytes,
int32Bytes(blockNumber), int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)), hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length), int16Bytes(textBytes.length),
@@ -607,9 +607,9 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
); );
} }
function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text }) { function makeTextRatingBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text }) {
const cleanBch = String(toBlockchainName || '').trim(); const cleanLogin = String(toLogin || '').trim();
if (!cleanBch) throw new Error('toBlockchainName is required for rating'); if (!cleanLogin) throw new Error('toLogin is required for rating');
const blockNumber = Number(toBlockNumber); const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) { if (!Number.isFinite(blockNumber) || blockNumber < 0) {
@@ -619,9 +619,9 @@ function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashH
const message = String(text || '').trim(); const message = String(text || '').trim();
if (!message) throw new Error('Rating text is required'); if (!message) throw new Error('Rating text is required');
const bchBytes = utf8Bytes(cleanBch); const loginBytes = utf8Bytes(cleanLogin);
if (bchBytes.length < 1 || bchBytes.length > 255) { if (loginBytes.length < 1 || loginBytes.length > 255) {
throw new Error('toBlockchainName must be 1..255 bytes'); throw new Error('toLogin must be 1..255 bytes');
} }
const textBytes = utf8Bytes(message); const textBytes = utf8Bytes(message);
@@ -630,8 +630,8 @@ function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashH
} }
return concatBytes( return concatBytes(
int8Byte(bchBytes.length), int8Byte(loginBytes.length),
bchBytes, loginBytes,
int32Bytes(blockNumber), int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)), hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length), int16Bytes(textBytes.length),
@@ -639,18 +639,18 @@ function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashH
); );
} }
function makeStatusActionBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text = '' }) { function makeStatusActionBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text = '' }) {
const cleanBch = String(toBlockchainName || '').trim(); const cleanLogin = String(toLogin || '').trim();
if (!cleanBch) throw new Error('toBlockchainName is required for status action'); if (!cleanLogin) throw new Error('toLogin is required for status action');
const blockNumber = Number(toBlockNumber); const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) { if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for status action'); throw new Error('Invalid toBlockNumber for status action');
} }
const bchBytes = utf8Bytes(cleanBch); const loginBytes = utf8Bytes(cleanLogin);
if (bchBytes.length < 1 || bchBytes.length > 255) { if (loginBytes.length < 1 || loginBytes.length > 255) {
throw new Error('toBlockchainName must be 1..255 bytes'); throw new Error('toLogin must be 1..255 bytes');
} }
const textBytes = utf8Bytes(String(text || '').trim()); const textBytes = utf8Bytes(String(text || '').trim());
@@ -659,8 +659,8 @@ function makeStatusActionBodyBytes({ toBlockchainName, toBlockNumber, toBlockHas
} }
return concatBytes( return concatBytes(
int8Byte(bchBytes.length), int8Byte(loginBytes.length),
bchBytes, loginBytes,
int32Bytes(blockNumber), int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)), hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length), int16Bytes(textBytes.length),
@@ -673,7 +673,7 @@ function makeTextRepostBodyBytes({
prevLineNumber, prevLineNumber,
prevLineHashHex, prevLineHashHex,
thisLineNumber, thisLineNumber,
toBlockchainName, toLogin,
toBlockNumber, toBlockNumber,
toBlockHashHex, toBlockHashHex,
text, text,
@@ -681,11 +681,11 @@ function makeTextRepostBodyBytes({
const message = String(text || '').trim(); const message = String(text || '').trim();
if (!message) throw new Error('Комментарий к репосту обязателен'); if (!message) throw new Error('Комментарий к репосту обязателен');
const bch = String(toBlockchainName || '').trim(); const cleanTargetLogin = String(toLogin || '').trim();
if (!bch) throw new Error('toBlockchainName is required for repost'); if (!cleanTargetLogin) throw new Error('toLogin is required for repost');
const bchBytes = utf8Bytes(bch); const loginBytes = utf8Bytes(cleanTargetLogin);
if (bchBytes.length < 1 || bchBytes.length > 255) { if (loginBytes.length < 1 || loginBytes.length > 255) {
throw new Error('toBlockchainName must be 1..255 bytes'); throw new Error('toLogin must be 1..255 bytes');
} }
const blockNumber = Number(toBlockNumber); const blockNumber = Number(toBlockNumber);
@@ -703,8 +703,8 @@ function makeTextRepostBodyBytes({
int32Bytes(prevLineNumber), int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)), hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber), int32Bytes(thisLineNumber),
int8Byte(bchBytes.length), int8Byte(loginBytes.length),
bchBytes, loginBytes,
int32Bytes(blockNumber), int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)), hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length), int16Bytes(textBytes.length),
@@ -717,11 +717,16 @@ function makeTextEditPostBodyBytes({
prevLineNumber, prevLineNumber,
prevLineHashHex, prevLineHashHex,
thisLineNumber, thisLineNumber,
toLogin,
toBlockNumber, toBlockNumber,
toBlockHashHex, toBlockHashHex,
text, text,
}) { }) {
const message = String(text || '').trim(); const message = String(text || '').trim();
const cleanTargetLogin = String(toLogin || '').trim();
if (!cleanTargetLogin) throw new Error('toLogin is required for edit post');
const loginBytes = utf8Bytes(cleanTargetLogin);
if (loginBytes.length < 1 || loginBytes.length > 255) throw new Error('toLogin must be 1..255 bytes');
const targetBlockNumber = Number(toBlockNumber); const targetBlockNumber = Number(toBlockNumber);
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) { if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
throw new Error('Invalid target block number for edit post'); throw new Error('Invalid target block number for edit post');
@@ -736,6 +741,8 @@ function makeTextEditPostBodyBytes({
int32Bytes(prevLineNumber), int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)), hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber), int32Bytes(thisLineNumber),
int8Byte(loginBytes.length),
loginBytes,
int32Bytes(targetBlockNumber), int32Bytes(targetBlockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)), hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length), int16Bytes(textBytes.length),
@@ -743,8 +750,12 @@ function makeTextEditPostBodyBytes({
); );
} }
function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) { function makeTextEditReplyBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text }) {
const message = String(text || '').trim(); const message = String(text || '').trim();
const cleanTargetLogin = String(toLogin || '').trim();
if (!cleanTargetLogin) throw new Error('toLogin is required for edit reply');
const loginBytes = utf8Bytes(cleanTargetLogin);
if (loginBytes.length < 1 || loginBytes.length > 255) throw new Error('toLogin must be 1..255 bytes');
const targetBlockNumber = Number(toBlockNumber); const targetBlockNumber = Number(toBlockNumber);
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) { if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
throw new Error('Invalid target block number for edit reply'); throw new Error('Invalid target block number for edit reply');
@@ -755,6 +766,8 @@ function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
} }
return concatBytes( return concatBytes(
int8Byte(loginBytes.length),
loginBytes,
int32Bytes(targetBlockNumber), int32Bytes(targetBlockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)), hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length), int16Bytes(textBytes.length),
@@ -767,21 +780,21 @@ function makeConnectionBodyBytes({
prevLineNumber = -1, prevLineNumber = -1,
prevLineHashHex = ZERO64, prevLineHashHex = ZERO64,
thisLineNumber = -1, thisLineNumber = -1,
toBlockchainName, toLogin,
toBlockNumber, toBlockNumber,
toBlockHashHex, toBlockHashHex,
}) { }) {
const cleanBch = String(toBlockchainName || '').trim(); const cleanTargetLogin = String(toLogin || '').trim();
if (!cleanBch) throw new Error('toBlockchainName is required for connection'); if (!cleanTargetLogin) throw new Error('toLogin is required for connection');
const blockNumber = Number(toBlockNumber); const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) { if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for connection'); throw new Error('Invalid toBlockNumber for connection');
} }
const bchBytes = utf8Bytes(cleanBch); const loginBytes = utf8Bytes(cleanTargetLogin);
if (bchBytes.length < 1 || bchBytes.length > 255) { if (loginBytes.length < 1 || loginBytes.length > 255) {
throw new Error('toBlockchainName must be 1..255 bytes'); throw new Error('toLogin must be 1..255 bytes');
} }
return concatBytes( return concatBytes(
@@ -789,8 +802,8 @@ function makeConnectionBodyBytes({
int32Bytes(prevLineNumber), int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)), hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber), int32Bytes(thisLineNumber),
int8Byte(bchBytes.length), int8Byte(loginBytes.length),
bchBytes, loginBytes,
int32Bytes(blockNumber), int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)) hexToBytes(normalizeHex32(toBlockHashHex))
); );
@@ -938,13 +951,20 @@ function makeTextLineBodyBytesAllowEmpty({ lineCode, prevLineNumber, prevLineHas
); );
} }
function normalizeMessageRefTarget(target, actionName = 'action') { function loginFromBlockchainNameValue(value) {
const cleanBch = String(target?.blockchainName || '').trim(); const name = String(value || '').trim();
const cleanBlockNumber = Number(target?.blockNumber); const match = name.match(/^(.*)-\d{3}$/u);
const cleanBlockHash = String(target?.blockHash || '').trim().toLowerCase(); return match?.[1] ? match[1] : '';
}
if (!cleanBch) { function normalizeMessageRefTarget(target, actionName = 'action') {
throw new Error(`Missing message target blockchain for ${actionName}`); const cleanBch = String(target?.blockchainName || target?.authorBlockchainName || '').trim();
const cleanLogin = String(target?.login || target?.authorLogin || target?.ownerLogin || loginFromBlockchainNameValue(cleanBch)).trim();
const cleanBlockNumber = Number(target?.blockNumber ?? target?.messageRef?.blockNumber);
const cleanBlockHash = String(target?.blockHash ?? target?.messageRef?.blockHash ?? '').trim().toLowerCase();
if (!cleanLogin) {
throw new Error(`Missing message target login for ${actionName}`);
} }
if (!Number.isFinite(cleanBlockNumber) || cleanBlockNumber < 0) { if (!Number.isFinite(cleanBlockNumber) || cleanBlockNumber < 0) {
throw new Error(`Invalid message target block number for ${actionName}`); throw new Error(`Invalid message target block number for ${actionName}`);
@@ -954,6 +974,7 @@ function normalizeMessageRefTarget(target, actionName = 'action') {
} }
return { return {
login: cleanLogin,
blockchainName: cleanBch, blockchainName: cleanBch,
blockNumber: cleanBlockNumber, blockNumber: cleanBlockNumber,
blockHash: cleanBlockHash, blockHash: cleanBlockHash,
@@ -1892,7 +1913,7 @@ export class AuthService {
return this.runWriteLocked(key, async () => { return this.runWriteLocked(key, async () => {
const bodyBytes = makeReactionLikeBodyBytes({ const bodyBytes = makeReactionLikeBodyBytes({
toBlockchainName: target.blockchainName, toLogin: target.login,
toBlockNumber: target.blockNumber, toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash, toBlockHashHex: target.blockHash,
}); });
@@ -1915,7 +1936,7 @@ export class AuthService {
return this.runWriteLocked(key, async () => { return this.runWriteLocked(key, async () => {
const bodyBytes = makeReactionLikeBodyBytes({ const bodyBytes = makeReactionLikeBodyBytes({
toBlockchainName: target.blockchainName, toLogin: target.login,
toBlockNumber: target.blockNumber, toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash, toBlockHashHex: target.blockHash,
}); });
@@ -1939,7 +1960,7 @@ export class AuthService {
return this.runWriteLocked(key, async () => { return this.runWriteLocked(key, async () => {
const bodyBytes = makeTextReplyBodyBytes({ const bodyBytes = makeTextReplyBodyBytes({
toBlockchainName: target.blockchainName, toLogin: target.login,
toBlockNumber: target.blockNumber, toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash, toBlockHashHex: target.blockHash,
text: cleanText, text: cleanText,
@@ -1964,7 +1985,7 @@ export class AuthService {
return this.runWriteLocked(key, async () => { return this.runWriteLocked(key, async () => {
const bodyBytes = makeTextRatingBodyBytes({ const bodyBytes = makeTextRatingBodyBytes({
toBlockchainName: target.blockchainName, toLogin: target.login,
toBlockNumber: target.blockNumber, toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash, toBlockHashHex: target.blockHash,
text: cleanText, text: cleanText,
@@ -2004,7 +2025,7 @@ export class AuthService {
return this.runWriteLocked(key, async () => { return this.runWriteLocked(key, async () => {
const bodyBytes = makeStatusActionBodyBytes({ const bodyBytes = makeStatusActionBodyBytes({
toBlockchainName: target.blockchainName, toLogin: target.login,
toBlockNumber: target.blockNumber, toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash, toBlockHashHex: target.blockHash,
text: cleanText, text: cleanText,
@@ -2077,7 +2098,7 @@ export class AuthService {
prevLineNumber, prevLineNumber,
prevLineHashHex, prevLineHashHex,
thisLineNumber, thisLineNumber,
toBlockchainName: target.blockchainName, toLogin: target.login,
toBlockNumber: target.blockNumber, toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash, toBlockHashHex: target.blockHash,
text: cleanText, text: cleanText,
@@ -2159,6 +2180,7 @@ export class AuthService {
prevLineNumber, prevLineNumber,
prevLineHashHex, prevLineHashHex,
thisLineNumber, thisLineNumber,
toLogin: target.login,
toBlockNumber: target.blockNumber, toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash, toBlockHashHex: target.blockHash,
text: cleanText, text: cleanText,
@@ -2176,6 +2198,7 @@ export class AuthService {
} }
const bodyBytes = makeTextEditReplyBodyBytes({ const bodyBytes = makeTextEditReplyBodyBytes({
toLogin: target.login,
toBlockNumber: target.blockNumber, toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash, toBlockHashHex: target.blockHash,
text: cleanText, text: cleanText,
@@ -2206,6 +2229,7 @@ export class AuthService {
return this.addBlockFollowChannel({ return this.addBlockFollowChannel({
login: cleanLogin, login: cleanLogin,
storagePwd, storagePwd,
targetLogin: cleanTargetLogin,
targetBlockchainName: targetUser.blockchainName, targetBlockchainName: targetUser.blockchainName,
targetBlockNumber: 0, targetBlockNumber: 0,
targetBlockHashHex: targetHeaderHash, targetBlockHashHex: targetHeaderHash,
@@ -2217,6 +2241,7 @@ export class AuthService {
async addBlockFollowChannel({ async addBlockFollowChannel({
login, login,
storagePwd, storagePwd,
targetLogin = '',
targetBlockchainName, targetBlockchainName,
targetBlockNumber, targetBlockNumber,
targetBlockHashHex, targetBlockHashHex,
@@ -2224,8 +2249,10 @@ export class AuthService {
}) { }) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
const cleanTargetBch = String(targetBlockchainName || '').trim(); const cleanTargetBch = String(targetBlockchainName || '').trim();
const cleanTargetLogin = String(targetLogin || loginFromBlockchainNameValue(cleanTargetBch)).trim();
const cleanTargetBlockNumber = Number(targetBlockNumber); const cleanTargetBlockNumber = Number(targetBlockNumber);
if (!cleanTargetBch) throw new Error('Target blockchain is required'); if (!cleanTargetBch) throw new Error('Target blockchain is required');
if (!cleanTargetLogin) throw new Error('Target login is required');
if (!Number.isFinite(cleanTargetBlockNumber) || cleanTargetBlockNumber < 0) { if (!Number.isFinite(cleanTargetBlockNumber) || cleanTargetBlockNumber < 0) {
throw new Error('Invalid target block number'); throw new Error('Invalid target block number');
} }
@@ -2245,7 +2272,7 @@ export class AuthService {
prevLineNumber: -1, prevLineNumber: -1,
prevLineHashHex: ZERO64, prevLineHashHex: ZERO64,
thisLineNumber: -1, thisLineNumber: -1,
toBlockchainName: cleanTargetBch, toLogin: cleanTargetLogin,
toBlockNumber: cleanTargetBlockNumber, toBlockNumber: cleanTargetBlockNumber,
toBlockHashHex: targetHashHex, toBlockHashHex: targetHashHex,
}); });
@@ -3228,13 +3255,12 @@ export class AuthService {
const targetUser = await this.getUser(cleanToLogin); const targetUser = await this.getUser(cleanToLogin);
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.'); if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();
const bodyBytes = makeConnectionBodyBytes({ const bodyBytes = makeConnectionBodyBytes({
lineCode: 0, lineCode: 0,
prevLineNumber: -1, prevLineNumber: -1,
prevLineHashHex: ZERO_HASH_HEX, prevLineHashHex: ZERO_HASH_HEX,
thisLineNumber: -1, thisLineNumber: -1,
toBlockchainName, toLogin: cleanToLogin,
toBlockNumber: 0, toBlockNumber: 0,
toBlockHashHex: ZERO_HASH_HEX, toBlockHashHex: ZERO_HASH_HEX,
}); });
@@ -0,0 +1,168 @@
import { createAns104DataItem, createAns104DataItemWithRawParts, parseAns104DataItem } from './ans104-data-item.js';
import { base64ToBytes, bytesToBase64 } from './crypto-utils.js';
import { updateShineUserPdaOnSolana } from './shine-user-pda-service.js';
import { updateEncryptedUserSecrets } from './key-vault.js';
const TE = new TextEncoder();
const ZERO_HASH = '0'.repeat(64);
const TECH_FORK_SUBTYPE = 2;
function payload(resp) { return resp?.payload && typeof resp.payload === 'object' ? resp.payload : (resp || {}); }
function assertOk(resp, op) {
if (Number(resp?.status) >= 200 && Number(resp?.status) < 300) return payload(resp);
const p = payload(resp);
const message = p?.message || p?.error || p?.code || `${op} failed (${resp?.status ?? '?'})`;
const error = new Error(String(message));
error.code = p?.code || p?.errorCode || '';
error.response = resp;
throw error;
}
function concat(...chunks) {
const len = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(len); let p = 0;
for (const c of chunks) { out.set(c, p); p += c.length; }
return out;
}
function be16(v) { const a=new Uint8Array(2); new DataView(a.buffer).setUint16(0, Number(v), false); return a; }
function be32(v) { const a=new Uint8Array(4); new DataView(a.buffer).setUint32(0, Number(v), false); return a; }
function be64(v) { const a=new Uint8Array(8); new DataView(a.buffer).setBigInt64(0, BigInt(v), false); return a; }
function hexToBytes(hex) {
const s=String(hex||'').trim().toLowerCase(); if(!/^[0-9a-f]{64}$/.test(s)) throw new Error('Ожидался SHA-256 hash');
const out=new Uint8Array(32); for(let i=0;i<32;i++) out[i]=parseInt(s.slice(i*2,i*2+2),16); return out;
}
function bytesToHex(bytes) { return Array.from(bytes || [], (b)=>b.toString(16).padStart(2,'0')).join(''); }
function normalizeComment(value) { return String(value || '').trim().replace(/\r\n/g,'\n').replace(/\r/g,'\n'); }
function readFrameTimestampMs(frame) {
if (!(frame instanceof Uint8Array) || frame.length < 50) throw new Error('Некорректный SHiNE Frame');
const seconds = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getBigInt64(42, false);
return Number(seconds * 1000n);
}
function makeForkBody({ oldBlockchainKeyB64, forkPointBlock, forkPointHash, forkPointTimestampMs, parentTipBlock, parentTipHash, parentTipTimestampMs, reasonCode, comment }) {
const oldKey=base64ToBytes(oldBlockchainKeyB64); if(oldKey.length!==32) throw new Error('Некорректный старый blockchain key');
const c=TE.encode(normalizeComment(comment)); if(c.length>1024) throw new Error('Комментарий больше 1024 UTF-8 байт');
const discarded=Number(parentTipBlock)-Number(forkPointBlock);
if(discarded<0) throw new Error('Некорректная точка fork');
return concat(oldKey,be32(forkPointBlock),hexToBytes(forkPointHash),be64(forkPointTimestampMs),be32(parentTipBlock),hexToBytes(parentTipHash),be64(parentTipTimestampMs),be32(discarded),Uint8Array.of(Number(reasonCode)),be16(c.length),c);
}
function makeFrame({ prevHash, blockNumber, type, subType, version=1, body, timestampMs=Date.now() }) {
const bodyBytes=body || new Uint8Array(0);
const size=2+32+4+4+8+2+2+2+bodyBytes.length;
return concat(be16(1),hexToBytes(prevHash),be32(size),be32(blockNumber),be64(Math.floor(Number(timestampMs)/1000)),be16(type),be16(subType),be16(version),bodyBytes);
}
export const KEY_ROTATION_REASONS = Object.freeze([
{ code:1, label:'Обычная смена пароля / ключей' },
{ code:2, label:'Возможная компрометация ключей' },
{ code:3, label:'Подтверждённая компрометация — откат истории' },
{ code:4, label:'Восстановление доступа' },
]);
export class KeyRotationClient {
constructor(authService) { this.auth = authService; }
async status() { return assertOk(await this.auth.ws.request('KeyRotationStatus', {}), 'KeyRotationStatus'); }
async start(args) { return assertOk(await this.auth.ws.request('KeyRotationStart', args), 'KeyRotationStart'); }
async abort() { return assertOk(await this.auth.ws.request('KeyRotationAbort', {}), 'KeyRotationAbort'); }
async continuePlaceholder() { return assertOk(await this.auth.ws.request('KeyRotationContinue', {}), 'KeyRotationContinue'); }
async finishChain() { return assertOk(await this.auth.ws.request('KeyRotationFinishChain', {}), 'KeyRotationFinishChain'); }
async notifyPdaRotation(signature) { return assertOk(await this.auth.ws.request('KeyRotationRotatePda', { pdaRotationSignature: signature }), 'KeyRotationRotatePda'); }
async getMyBlockchain({ beforeBlock=null, limit=50, includeBlockBytes=false }={}) {
return assertOk(await this.auth.ws.request('GetMyBlockchain', { beforeBlock, limit, includeBlockBytes }), 'GetMyBlockchain');
}
async getBlock(blockchainName, blockNumber) {
return assertOk(await this.auth.ws.request('GetBlockchainBlock', { blockchainName, blockNumber }), 'GetBlockchainBlock');
}
async addCandidate({ blockNumber, prevBlockHash, blockBytesB64 }) {
return assertOk(await this.auth.ws.request('KeyRotationAddBlock', { blockNumber, prevBlockHash, blockBytesB64 }, 30000), 'KeyRotationAddBlock');
}
async copyCandidateChain({ rotation, newBundle, onProgress=()=>{} }) {
const cutoff=Number(rotation.forkFromBlock); const source=String(rotation.sourceBlockchainName||'');
if(!source || !Number.isInteger(cutoff) || cutoff<0) throw new Error('Некорректное состояние ротации');
if(String(newBundle?.blockchainPair?.publicKeyB64||'') !== String(rotation.newBlockchainKey||'')) throw new Error('Новый пароль выводит другой blockchain key');
const owner32=base64ToBytes(newBundle.blockchainPair.publicKeyB64);
const privateKey=newBundle.blockchainPair.privateKey;
let start=Math.max(0, Math.min(cutoff+1, Number(rotation.progressCurrent)||0));
// progressCurrent tracks published, while server may already have more pending blocks. Re-sends are idempotent.
for(let n=start;n<=cutoff;n++) {
const src=await this.getBlock(source,n);
const parsed=parseAns104DataItem(base64ToBytes(src.blockBytesB64));
const raw=await createAns104DataItemWithRawParts({ owner32, privateKey, data:parsed.data, rawTags:parsed.rawTags, tagsCount:parsed.tagsCount, target:parsed.target, anchor:parsed.anchor });
await this.addCandidate({ blockNumber:n, prevBlockHash:n===0?ZERO_HASH:nullIfEmpty(src.prevBlockHash)||await this.#sourcePrevHash(source,n), blockBytesB64:bytesToBase64(raw) });
onProgress({ phase:'copy', current:n+1, total:cutoff+2 });
}
const forkSource=await this.getBlock(source,cutoff);
const tipBlock=Number(rotation.sourceTipBlock);
const tipSource=tipBlock===cutoff ? forkSource : await this.getBlock(source,tipBlock);
const forkParsed=parseAns104DataItem(base64ToBytes(forkSource.blockBytesB64));
const tipParsed=parseAns104DataItem(base64ToBytes(tipSource.blockBytesB64));
const forkBody=makeForkBody({
oldBlockchainKeyB64:rotation.oldBlockchainKey,
forkPointBlock:cutoff,
forkPointHash:rotation.forkFromHash,
forkPointTimestampMs:readFrameTimestampMs(forkParsed.data),
parentTipBlock:tipBlock,
parentTipHash:rotation.sourceTipHash,
parentTipTimestampMs:readFrameTimestampMs(tipParsed.data),
reasonCode:rotation.reasonCode,
comment:rotation.comment,
});
const techFrame=makeFrame({ prevHash:rotation.forkFromHash, blockNumber:cutoff+1, type:0, subType:TECH_FORK_SUBTYPE, version:1, body:forkBody });
const techRaw=await createAns104DataItem({ owner32, privateKey, data:techFrame, tags:[{name:'App',value:'test5590'}] });
await this.addCandidate({ blockNumber:cutoff+1, prevBlockHash:rotation.forkFromHash, blockBytesB64:bytesToBase64(techRaw) });
onProgress({ phase:'copy', current:cutoff+2, total:cutoff+2 });
}
async #sourcePrevHash(source, blockNumber) {
if(blockNumber<=0) return ZERO_HASH;
const prev=await this.getBlock(source,blockNumber-1);
return String(prev.blockHash||'');
}
async waitUntilPublished({ timeoutMs=180000, onProgress=()=>{} }={}) {
const started=Date.now();
while(Date.now()-started<timeoutMs) {
const s=await this.status();
onProgress({ phase:'publish', current:Number(s.progressCurrent)||0, total:Number(s.progressTotal)||0, status:s.rotationStatus });
if(s.rotationStatus!=='COPYING_CHAIN') return s;
if(Number(s.progressTotal)>0 && Number(s.progressCurrent)>=Number(s.progressTotal)) return s;
await new Promise(r=>setTimeout(r,1500));
}
throw new Error('Публикация candidate-цепочки ещё не завершилась. Можно закрыть окно и продолжить позже.');
}
async rotatePda({ login, solanaEndpoint, oldBundle, newBundle, storagePwd }) {
const rotation=await this.status();
if(rotation.rotationStatus!=='CHAIN_READY' && rotation.rotationStatus!=='ROTATING_PDA') throw new Error('Новая цепочка ещё не готова к смене PDA');
const checks=[['root',oldBundle?.rootPair?.publicKeyB64,rotation.oldRootKey],['blockchain',oldBundle?.blockchainPair?.publicKeyB64,rotation.oldBlockchainKey],['client',oldBundle?.clientPair?.publicKeyB64,rotation.oldClientKey],['new root',newBundle?.rootPair?.publicKeyB64,rotation.newRootKey],['new blockchain',newBundle?.blockchainPair?.publicKeyB64,rotation.newBlockchainKey],['new client',newBundle?.clientPair?.publicKeyB64,rotation.newClientKey]];
for(const [name,actual,expected] of checks) if(String(actual||'')!==String(expected||'')) throw new Error(`Ключ «${name}» не соответствует начатой ротации`);
let signature=String(rotation.pdaRotationSignature||'');
if(!signature) {
const tx=await updateShineUserPdaOnSolana({
login, solanaEndpoint,
rootPrivatePkcs8B64:oldBundle.rootPair.privatePkcs8B64,
blockchainPrivatePkcs8B64:oldBundle.blockchainPair.privatePkcs8B64,
clientPrivatePkcs8B64:oldBundle.clientPair.privatePkcs8B64,
payerPrivatePkcs8B64:oldBundle.blockchainPair.privatePkcs8B64,
authorityMode:'root',
newRootPublicKey32:base64ToBytes(newBundle.rootPair.publicKeyB64),
nextClientPublicKey32:base64ToBytes(newBundle.clientPair.publicKeyB64),
newBlockchainPublicKey32:base64ToBytes(newBundle.blockchainPair.publicKeyB64),
newBlockchainPrivatePkcs8B64:newBundle.blockchainPair.privatePkcs8B64,
});
signature=String(tx.signature||'');
if(!signature) throw new Error('Solana не вернула signature ротации');
}
const state=await this.notifyPdaRotation(signature);
if(storagePwd) {
await updateEncryptedUserSecrets(login, storagePwd, (current)=>({
...(current||{}),
rootKey:newBundle.rootPair.privatePkcs8B64,
blockchainKey:newBundle.blockchainPair.privatePkcs8B64,
clientKey:newBundle.clientPair.privatePkcs8B64,
}));
}
return state;
}
}
function nullIfEmpty(value) { const s=String(value||'').trim(); return s || null; }
+33 -21
View File
@@ -557,7 +557,12 @@ async function createShineUserPdaOnSolana({
const clientKey32 = base64ToBytes(keyBundle.clientPair.publicKeyB64); const clientKey32 = base64ToBytes(keyBundle.clientPair.publicKeyB64);
const rootPriv = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64); const rootPriv = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64);
const bchPriv = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64); const bchPriv = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64);
const ctx = await buildCommonContext({ login: cleanLogin, clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, solanaEndpoint }); const ctx = await buildCommonContext({
login: cleanLogin,
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
payerPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
solanaEndpoint,
});
const ecoInfo = await ctx.connection.getAccountInfo(ctx.economyConfigPda, 'confirmed'); const ecoInfo = await ctx.connection.getAccountInfo(ctx.economyConfigPda, 'confirmed');
if (!ecoInfo?.data) throw new Error('Economy config не инициализирован.'); if (!ecoInfo?.data) throw new Error('Economy config не инициализирован.');
const startBonusLimit = parseUsersEconomyConfig(new Uint8Array(ecoInfo.data)).startBonusLimit; const startBonusLimit = parseUsersEconomyConfig(new Uint8Array(ecoInfo.data)).startBonusLimit;
@@ -603,7 +608,7 @@ async function createShineUserPdaOnSolana({
const createIx = new ctx.solana.TransactionInstruction({ const createIx = new ctx.solana.TransactionInstruction({
programId: ctx.usersProgram, programId: ctx.usersProgram,
keys: [ keys: [
{ pubkey: ctx.clientKeypair.publicKey, isSigner: true, isWritable: true }, { pubkey: ctx.payerKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: ctx.userPda, isSigner: false, isWritable: true }, { pubkey: ctx.userPda, isSigner: false, isWritable: true },
{ pubkey: ctx.solana.SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: ctx.solana.SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: ctx.inflowVault, isSigner: false, isWritable: true }, { pubkey: ctx.inflowVault, isSigner: false, isWritable: true },
@@ -618,7 +623,7 @@ async function createShineUserPdaOnSolana({
const tx = new ctx.solana.Transaction(); const tx = new ctx.solana.Transaction();
if (promoEdIx) tx.add(promoEdIx); if (promoEdIx) tx.add(promoEdIx);
tx.add(rootIx, recordIx, createIx); tx.add(rootIx, recordIx, createIx);
const signature = await ctx.solana.sendAndConfirmTransaction(ctx.connection, tx, [ctx.clientKeypair], { commitment: 'confirmed' }); const signature = await ctx.solana.sendAndConfirmTransaction(ctx.connection, tx, [ctx.payerKeypair], { commitment: 'confirmed' });
return { signature, userPda: ctx.userPda.toBase58(), pdaAddress: ctx.userPda.toBase58(), blockchainName: `${cleanLogin}-001` }; return { signature, userPda: ctx.userPda.toBase58(), pdaAddress: ctx.userPda.toBase58(), blockchainName: `${cleanLogin}-001` };
} catch (error) { throw await attachSolanaLogs(error, ctx.connection); } } catch (error) { throw await attachSolanaLogs(error, ctx.connection); }
} }
@@ -678,15 +683,27 @@ export async function updateShineUserPdaOnSolana({
accessServers, accessServers,
}) { }) {
const current = await readShineUserPda({ login, solanaEndpoint }); const current = await readShineUserPda({ login, solanaEndpoint });
const ctx = await buildCommonContext({ login: current.login, clientPrivatePkcs8B64, payerPrivatePkcs8B64, solanaEndpoint }); const ctx = await buildCommonContext({
login: current.login,
clientPrivatePkcs8B64,
payerPrivatePkcs8B64: String(payerPrivatePkcs8B64 || blockchainPrivatePkcs8B64 || ''),
solanaEndpoint,
});
const addLimit = BigInt(additionalLimitBytes || 0); const addLimit = BigInt(additionalLimitBytes || 0);
if (addLimit < 0n || addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`); if (addLimit < 0n || addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`);
const authMode = authorityMode === 'blockchain' || authorityMode === AUTH_MODE_BLOCKCHAIN ? AUTH_MODE_BLOCKCHAIN : AUTH_MODE_ROOT;
const rootKey = newRootPublicKey32 ? toUint8Array(newRootPublicKey32, 32) : current.rootKey; const rootKey = newRootPublicKey32 ? toUint8Array(newRootPublicKey32, 32) : current.rootKey;
const clientKey = nextClientPublicKey32 ? toUint8Array(nextClientPublicKey32, 32) : current.clientKey; const clientKey = nextClientPublicKey32 ? toUint8Array(nextClientPublicKey32, 32) : current.clientKey;
const rootChanged = bytesToBase58(rootKey) !== bytesToBase58(current.rootKey); const rootChanged = bytesToBase58(rootKey) !== bytesToBase58(current.rootKey);
if (authMode === AUTH_MODE_BLOCKCHAIN && rootChanged) throw new Error('Blockchain authority не может менять root key'); const clientChanged = bytesToBase58(clientKey) !== bytesToBase58(current.clientKey);
if (rootChanged && newBlockchainPublicKey32) throw new Error('Root rotation и blockchain fork нужно делать разными транзакциями'); const fullKeyRotation = rootChanged && clientChanged && Boolean(newBlockchainPublicKey32);
if ((rootChanged || clientChanged) && !fullKeyRotation) {
throw new Error('Root/client keys меняются только как полная ротация: новый root + новый client + новый blockchain fork');
}
const authMode = fullKeyRotation ? AUTH_MODE_ROOT : AUTH_MODE_BLOCKCHAIN;
if (authorityMode != null) {
const requestedMode = authorityMode === 'root' || authorityMode === AUTH_MODE_ROOT ? AUTH_MODE_ROOT : AUTH_MODE_BLOCKCHAIN;
if (requestedMode !== authMode) throw new Error(fullKeyRotation ? 'Полная ротация требует root authority' : 'Обычное обновление PDA выполняется blockchain authority');
}
const updatedAtMs = BigInt(Date.now()); const updatedAtMs = BigInt(Date.now());
const forks = current.forks.map((fork) => ({ blockchainKey: fork.blockchainKey, createdAtMs: BigInt(fork.createdAtMs), paidLimitBytes: BigInt(fork.paidLimitBytes) })); const forks = current.forks.map((fork) => ({ blockchainKey: fork.blockchainKey, createdAtMs: BigInt(fork.createdAtMs), paidLimitBytes: BigInt(fork.paidLimitBytes) }));
@@ -695,7 +712,7 @@ export async function updateShineUserPdaOnSolana({
appendedKey = toUint8Array(newBlockchainPublicKey32, 32); appendedKey = toUint8Array(newBlockchainPublicKey32, 32);
if (forks.some((f) => bytesToBase58(f.blockchainKey) === bytesToBase58(appendedKey))) throw new Error('Этот blockchain key уже был в истории fork'); if (forks.some((f) => bytesToBase58(f.blockchainKey) === bytesToBase58(appendedKey))) throw new Error('Этот blockchain key уже был в истории fork');
const lastForkAt = BigInt(forks.at(-1).createdAtMs); const lastForkAt = BigInt(forks.at(-1).createdAtMs);
if (authMode === AUTH_MODE_BLOCKCHAIN && updatedAtMs - lastForkAt < FORK_COOLDOWN_MS) throw new Error('Новый fork обычным blockchain authority можно создавать не чаще одного раза в 72 часа'); if (!fullKeyRotation && updatedAtMs - lastForkAt < FORK_COOLDOWN_MS) throw new Error('Новый fork обычным blockchain authority можно создавать не чаще одного раза в 72 часа');
const newLimit = forks.at(-1).paidLimitBytes + addLimit; const newLimit = forks.at(-1).paidLimitBytes + addLimit;
if (newLimit > 0xffffffffn) throw new Error('paid_limit_bytes превышает u32'); if (newLimit > 0xffffffffn) throw new Error('paid_limit_bytes превышает u32');
forks.push({ blockchainKey: appendedKey, createdAtMs: updatedAtMs, paidLimitBytes: newLimit }); forks.push({ blockchainKey: appendedKey, createdAtMs: updatedAtMs, paidLimitBytes: newLimit });
@@ -728,23 +745,17 @@ export async function updateShineUserPdaOnSolana({
const unsigned = serializeUnsignedRecordFromState(next); const unsigned = serializeUnsignedRecordFromState(next);
const hash = await sha256Bytes(unsigned); const hash = await sha256Bytes(unsigned);
const oldAuthorityPub = authMode === AUTH_MODE_ROOT ? current.rootKey : current.forks.at(-1).blockchainKey; const oldAuthorityPub = fullKeyRotation ? current.rootKey : current.forks.at(-1).blockchainKey;
const oldAuthorityPrivB64 = authMode === AUTH_MODE_ROOT ? rootPrivatePkcs8B64 : blockchainPrivatePkcs8B64; const oldAuthorityPrivB64 = fullKeyRotation ? rootPrivatePkcs8B64 : blockchainPrivatePkcs8B64;
if (!oldAuthorityPrivB64) throw new Error(authMode === AUTH_MODE_ROOT ? 'Нужен root private key' : 'Нужен blockchain private key'); if (!oldAuthorityPrivB64) throw new Error(fullKeyRotation ? 'Для полной ротации нужен старый root private key' : 'Для обновления PDA нужен blockchain private key');
const oldAuthorityPriv = await importPkcs8Ed25519(oldAuthorityPrivB64); const oldAuthorityPriv = await importPkcs8Ed25519(oldAuthorityPrivB64);
const authSig = await signBytes(oldAuthorityPriv, hash); const authSig = await signBytes(oldAuthorityPriv, hash);
let recordSignerPub; let recordSignerPub;
let recordSignerPrivB64; let recordSignerPrivB64;
if (rootChanged) { if (appendedKey) {
recordSignerPub = rootKey;
recordSignerPrivB64 = newRootPrivatePkcs8B64;
} else if (appendedKey) {
recordSignerPub = appendedKey; recordSignerPub = appendedKey;
recordSignerPrivB64 = newBlockchainPrivatePkcs8B64; recordSignerPrivB64 = newBlockchainPrivatePkcs8B64;
} else if (authMode === AUTH_MODE_ROOT) {
recordSignerPub = rootKey;
recordSignerPrivB64 = rootPrivatePkcs8B64;
} else { } else {
recordSignerPub = current.forks.at(-1).blockchainKey; recordSignerPub = current.forks.at(-1).blockchainKey;
recordSignerPrivB64 = blockchainPrivatePkcs8B64; recordSignerPrivB64 = blockchainPrivatePkcs8B64;
@@ -758,7 +769,7 @@ export async function updateShineUserPdaOnSolana({
const updateIx = new ctx.solana.TransactionInstruction({ const updateIx = new ctx.solana.TransactionInstruction({
programId: ctx.usersProgram, programId: ctx.usersProgram,
keys: [ keys: [
{ pubkey: ctx.clientKeypair.publicKey, isSigner: true, isWritable: true }, { pubkey: ctx.payerKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: ctx.userPda, isSigner: false, isWritable: true }, { pubkey: ctx.userPda, isSigner: false, isWritable: true },
{ pubkey: ctx.solana.SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: ctx.solana.SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: ctx.inflowVault, isSigner: false, isWritable: true }, { pubkey: ctx.inflowVault, isSigner: false, isWritable: true },
@@ -768,7 +779,7 @@ export async function updateShineUserPdaOnSolana({
data: serializeUpdateUserPdaArgs({ login: current.login, rootKey32: rootKey, updatedAtMs, additionalLimitBytes: addLimit, clientKey32: clientKey, authMode, newBlockchainKey32: appendedKey, serverAddresses: addresses, accessServers: nextAccess, recordSignature64: recordSig }), data: serializeUpdateUserPdaArgs({ login: current.login, rootKey32: rootKey, updatedAtMs, additionalLimitBytes: addLimit, clientKey32: clientKey, authMode, newBlockchainKey32: appendedKey, serverAddresses: addresses, accessServers: nextAccess, recordSignature64: recordSig }),
}); });
try { try {
const signature = await ctx.solana.sendAndConfirmTransaction(ctx.connection, new ctx.solana.Transaction().add(authIx, recordIx, updateIx), [ctx.clientKeypair], { commitment: 'confirmed' }); const signature = await ctx.solana.sendAndConfirmTransaction(ctx.connection, new ctx.solana.Transaction().add(authIx, recordIx, updateIx), [ctx.payerKeypair], { commitment: 'confirmed' });
return { signature, userPda: ctx.userPda.toBase58(), pdaAddress: ctx.userPda.toBase58(), paidLimitBytes: forks.at(-1).paidLimitBytes, forkCount: forks.length }; return { signature, userPda: ctx.userPda.toBase58(), pdaAddress: ctx.userPda.toBase58(), paidLimitBytes: forks.at(-1).paidLimitBytes, forkCount: forks.length };
} catch (error) { throw await attachSolanaLogs(error, ctx.connection); } } catch (error) { throw await attachSolanaLogs(error, ctx.connection); }
} }
@@ -778,8 +789,9 @@ export async function updateServerOnSolana({ login, keyBundle, serverAddress, se
login, login,
solanaEndpoint, solanaEndpoint,
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64, rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
authorityMode: 'root', authorityMode: 'blockchain',
serverAddresses: serverAddresses || [{ addressFormatType, addressFormatVersion, address: serverAddress }], serverAddresses: serverAddresses || [{ addressFormatType, addressFormatVersion, address: serverAddress }],
}); });
} }
+30 -31
View File
@@ -180,6 +180,26 @@ export async function getWalletFromStoredClientKey({ login, storagePwd }) {
}; };
} }
export async function getWalletFromStoredBlockchainKey({ login, storagePwd }) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
throw new Error('Нет активной сессии для доступа к blockchain.key');
}
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
const blockchainPrivate = String(secrets?.blockchainKey || '').trim();
if (!blockchainPrivate) {
throw new Error('На устройстве не найден blockchain.key');
}
const keypair = await keypairFromStoredSecret(blockchainPrivate);
return {
address: keypair.publicKey.toBase58(),
keypair,
blockchainPrivatePkcs8B64: blockchainPrivate,
};
}
export async function getWalletFromStoredRootKey({ login, storagePwd }) { export async function getWalletFromStoredRootKey({ login, storagePwd }) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim(); const cleanPwd = String(storagePwd || '').trim();
@@ -199,39 +219,18 @@ export async function getWalletFromStoredRootKey({ login, storagePwd }) {
}; };
} }
export async function getStoredSolanaWalletChoices({ login, storagePwd, includeRoot = true } = {}) { export async function getStoredSolanaWalletChoices({ login, storagePwd } = {}) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim(); const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) { if (!cleanLogin || !cleanPwd) throw new Error('Нет активной сессии для доступа к ключам');
throw new Error('Нет активной сессии для доступа к ключам'); const wallet = await getWalletFromStoredBlockchainKey({ login: cleanLogin, storagePwd: cleanPwd });
} return [{
id: 'blockchain-key',
const choices = []; keySource: 'blockchain',
const clientWallet = await getWalletFromStoredClientKey({ login: cleanLogin, storagePwd: cleanPwd }); label: 'blockchain key',
choices.push({ address: wallet.address,
id: 'client-key', keypair: wallet.keypair,
keySource: 'client', }];
label: 'client key',
address: clientWallet.address,
keypair: clientWallet.keypair,
});
if (includeRoot) {
try {
const rootWallet = await getWalletFromStoredRootKey({ login: cleanLogin, storagePwd: cleanPwd });
choices.push({
id: 'root-key',
keySource: 'root',
label: 'root key',
address: rootWallet.address,
keypair: rootWallet.keypair,
});
} catch {
// root key на устройстве может отсутствовать, это допустимо
}
}
return choices;
} }
export async function encodeSolanaSecretKeyBase58(secretKey) { export async function encodeSolanaSecretKeyBase58(secretKey) {
+10 -12
View File
@@ -25,8 +25,8 @@ function parseSolToLamports(amountSol) {
return lamports.toString(); return lamports.toString();
} }
function normalizeKeySource(value) { function normalizeKeySource() {
return String(value || '').trim().toLowerCase() === 'root' ? 'root' : 'client'; return 'blockchain';
} }
async function loadTurboLib() { async function loadTurboLib() {
@@ -36,14 +36,12 @@ async function loadTurboLib() {
return turboLibPromise; return turboLibPromise;
} }
async function getTurboWalletChoice({ login, storagePwd, keySource = 'client' } = {}) { async function getTurboWalletChoice({ login, storagePwd, keySource = 'blockchain' } = {}) {
const normalizedKeySource = normalizeKeySource(keySource); const normalizedKeySource = normalizeKeySource(keySource);
const choices = await getStoredSolanaWalletChoices({ login, storagePwd, includeRoot: true }); const choices = await getStoredSolanaWalletChoices({ login, storagePwd });
const selected = choices.find((item) => item.keySource === normalizedKeySource) || choices[0] || null; const selected = choices.find((item) => item.keySource === normalizedKeySource) || choices[0] || null;
if (!selected?.keypair) { if (!selected?.keypair) {
throw new Error(normalizedKeySource === 'root' throw new Error('На устройстве не найден blockchain key для Turbo.');
? 'На устройстве не найден root key для Turbo.'
: 'На устройстве не найден client key для Turbo.');
} }
return selected; return selected;
} }
@@ -90,7 +88,7 @@ export function formatTurboCredits(value, digits = 6) {
}); });
} }
export async function getTurboContextForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) { export async function getTurboContextForStoredSolanaKey({ login, storagePwd, keySource = 'blockchain' } = {}) {
const choice = await getTurboWalletChoice({ login, storagePwd, keySource }); const choice = await getTurboWalletChoice({ login, storagePwd, keySource });
const turbo = await createTurboClientFromWalletChoice(choice); const turbo = await createTurboClientFromWalletChoice(choice);
return { return {
@@ -103,7 +101,7 @@ export async function getTurboContextForStoredSolanaKey({ login, storagePwd, key
}; };
} }
export async function getTurboBalanceForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) { export async function getTurboBalanceForStoredSolanaKey({ login, storagePwd, keySource = 'blockchain' } = {}) {
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource }); const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
const balance = await ctx.turbo.getBalance(ctx.address); const balance = await ctx.turbo.getBalance(ctx.address);
const solanaBalance = await getBalanceSol({ const solanaBalance = await getBalanceSol({
@@ -123,7 +121,7 @@ export async function getTurboBalanceForStoredSolanaKey({ login, storagePwd, key
}; };
} }
export async function estimateTurboUploadPrice({ login, storagePwd, keySource = 'client', byteLength } = {}) { export async function estimateTurboUploadPrice({ login, storagePwd, keySource = 'blockchain', byteLength } = {}) {
const bytes = Number(byteLength); const bytes = Number(byteLength);
if (!Number.isInteger(bytes) || bytes <= 0) { if (!Number.isInteger(bytes) || bytes <= 0) {
throw new Error('Некорректный размер файла для Turbo.'); throw new Error('Некорректный размер файла для Turbo.');
@@ -147,7 +145,7 @@ export async function estimateTurboUploadPrice({ login, storagePwd, keySource =
export async function uploadFileWithTurbo({ export async function uploadFileWithTurbo({
login, login,
storagePwd, storagePwd,
keySource = 'client', keySource = 'blockchain',
file, file,
tags = [], tags = [],
shineType = 'attachment', shineType = 'attachment',
@@ -191,7 +189,7 @@ export async function uploadFileWithTurbo({
export async function topUpTurboWithSolana({ export async function topUpTurboWithSolana({
login, login,
storagePwd, storagePwd,
keySource = 'client', keySource = 'blockchain',
amountSol, amountSol,
turboCreditDestinationAddress = '', turboCreditDestinationAddress = '',
} = {}) { } = {}) {
@@ -200,7 +200,7 @@ $('btnUpdate').addEventListener('click', async () => {
if (!serverAddress) throw new Error('Укажите адрес сервера'); if (!serverAddress) throw new Error('Укажите адрес сервера');
setStatus($('status'), 'Проверка и сборка keyBundle...', 'info'); setStatus($('status'), 'Проверка и сборка keyBundle...', 'info');
const { keyBundle, normalized } = await buildKeyBundleFromForm(fieldMap, { requireBlockchain: false }); const { keyBundle, normalized } = await buildKeyBundleFromForm(fieldMap, { requireBlockchain: true });
$('rootPub').value = normalized.rootPubB58; $('rootPub').value = normalized.rootPubB58;
$('rootPriv').value = normalized.rootPrivB58; $('rootPriv').value = normalized.rootPrivB58;
$('bchPub').value = normalized.bchPubB58; $('bchPub').value = normalized.bchPubB58;
@@ -64,7 +64,7 @@ block_version: u8 = 0
root_key: [u8;32] root_key: [u8;32]
``` ```
`root_key` — холодный recovery authority. Blockchain authority не может изменить root. Root authority может изменить root и остальные поля. `root_key` — холодный recovery authority. Обычные update не используют root. Root участвует только в полной ротации ключей: старый root разрешает переход на одновременно новые `root_key`, `client_key` и blockchain fork.
## ClientKeyBlock ## ClientKeyBlock
@@ -74,7 +74,7 @@ block_version: u8 = 0
client_key: [u8;32] client_key: [u8;32]
``` ```
Client key не является authority PDA. Он может использоваться клиентским уровнем и как fee payer Solana-транзакции. Client key не является authority PDA. Он может использоваться клиентским уровнем и как fee payer Solana-транзакции. В текущем протоколе смена `client_key` разрешена только как часть полной ротации вместе с root и новым blockchain fork.
## BlockchainRegistryBlock ## BlockchainRegistryBlock
@@ -148,12 +148,13 @@ PDA 1.2 разрешает `server_count = 0` или `1`. Count оставлен
- обычный update — активный blockchain key; - обычный update — активный blockchain key;
- новый fork — новый blockchain key; - новый fork — новый blockchain key;
- root-mode — root нового состояния; - полная ротация root + client + blockchain fork — новый blockchain key.
- смена root — новый root.
На update программа дополнительно требует Ed25519-подпись authority предыдущего состояния по тому же hash нового unsigned state. Эта transition-подпись не хранится внутри PDA. На update программа дополнительно требует Ed25519-подпись authority предыдущего состояния по тому же hash нового unsigned state. Эта transition-подпись не хранится внутри PDA.
Blockchain-mode не может изменить root. Root-mode может изменить root и выполнить recovery. Одновременно менять root и добавлять blockchain fork одной транзакцией запрещено. Blockchain-mode не может изменить root. Для полной ротации используется root-mode: текущий root подписывает hash нового unsigned PDA state как разрешение на переход, а новый blockchain key подписывает тот же hash и его `signature[64]` сохраняется в PDA. Поскольку hash считается по всему unsigned state, обе подписи одновременно фиксируют новый root key, новый client key, новый blockchain fork и остальные поля записи.
Смена root считается корректной только как полная ротация: root key изменён, client key изменён и в ту же запись добавлен новый blockchain fork. Такая recovery-ротация не ограничивается обычным 72-часовым cooldown blockchain-authority.
## Create ## Create
+10 -5
View File
@@ -84,17 +84,22 @@ record_signature:[64]
Blockchain-mode: Blockchain-mode:
- используется для всех обычных update;
- старый authority = последний blockchain key; - старый authority = последний blockchain key;
- root менять нельзя; - `signature[64]` нового состояния = активный blockchain key либо новый blockchain key, если добавляется fork;
- root/client менять нельзя;
- новый fork — только после 72 часов от времени активного fork. - новый fork — только после 72 часов от времени активного fork.
Root-mode: Root-mode:
- старый authority = root; - допустим только для recovery/полной ротации ключей;
- root можно менять; - старый authority = текущий root;
- recovery fork может обходить 72-часовой cooldown. - полная ротация одной транзакцией меняет `root_key`, `client_key` и добавляет новый blockchain fork;
- transition-подпись делает старый root, а `signature[64]` нового состояния делает новый blockchain key;
- recovery fork может обходить 72-часовой cooldown;
- смена root без одновременной смены client key и добавления нового blockchain fork отклоняется.
Root rotation и blockchain fork одной транзакцией запрещены. Обе Ed25519-подписи проверяются по одному и тому же hash нового unsigned PDA state, поэтому новые root/client/blockchain public keys входят в подписываемое состояние.
### `5 upsert_promo_seller` ### `5 upsert_promo_seller`
Без изменений. Без изменений.
@@ -90,6 +90,7 @@ enum ShineUsersError {
CannotCloseCurrentFormat = 37, CannotCloseCurrentFormat = 37,
RootChangeRequiresRoot = 38, RootChangeRequiresRoot = 38,
AmbiguousAuthorityRotation = 39, AmbiguousAuthorityRotation = 39,
RootModeRequiresFullRotation = 40,
} }
impl From<ShineUsersError> for ProgramError { impl From<ShineUsersError> for ProgramError {
@@ -619,15 +620,20 @@ fn process_update_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args:
require!(args.auth_mode == AUTH_MODE_BLOCKCHAIN || args.auth_mode == AUTH_MODE_ROOT, ShineUsersError::InvalidInstruction); require!(args.auth_mode == AUTH_MODE_BLOCKCHAIN || args.auth_mode == AUTH_MODE_ROOT, ShineUsersError::InvalidInstruction);
let root_changed = args.root_key != old_record.root_key; let root_changed = args.root_key != old_record.root_key;
if args.auth_mode == AUTH_MODE_BLOCKCHAIN { let client_changed = args.client_key != old_record.client_key;
require!(!root_changed, ShineUsersError::RootChangeRequiresRoot); let full_key_rotation = root_changed && client_changed && args.new_blockchain_key.is_some();
}
if root_changed && args.new_blockchain_key.is_some() { // root/client меняются только вместе с созданием нового blockchain fork.
return Err(ProgramError::from(ShineUsersError::AmbiguousAuthorityRotation)); // Обычные update и обычный fork всегда авторизуются текущим blockchain key.
if root_changed || client_changed {
require!(full_key_rotation, ShineUsersError::AmbiguousAuthorityRotation);
require!(args.auth_mode == AUTH_MODE_ROOT, ShineUsersError::RootChangeRequiresRoot);
} else {
require!(args.auth_mode == AUTH_MODE_BLOCKCHAIN, ShineUsersError::RootModeRequiresFullRotation);
} }
let old_active_key = old_record.active_fork()?.blockchain_key; let old_active_key = old_record.active_fork()?.blockchain_key;
let old_authority = if args.auth_mode == AUTH_MODE_ROOT { old_record.root_key } else { old_active_key }; let old_authority = if full_key_rotation { old_record.root_key } else { old_active_key };
let mut forks = old_record.forks.clone(); let mut forks = old_record.forks.clone();
let additional_limit_u32 = u32::try_from(args.additional_limit).map_err(|_| ProgramError::from(ShineUsersError::PaidLimitTooLarge))?; let additional_limit_u32 = u32::try_from(args.additional_limit).map_err(|_| ProgramError::from(ShineUsersError::PaidLimitTooLarge))?;
@@ -636,7 +642,7 @@ fn process_update_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args:
for fork in &forks { for fork in &forks {
require!(fork.blockchain_key != new_key, ShineUsersError::DuplicateBlockchainKey); require!(fork.blockchain_key != new_key, ShineUsersError::DuplicateBlockchainKey);
} }
if args.auth_mode == AUTH_MODE_BLOCKCHAIN { if !full_key_rotation {
enforce_fork_cooldown(old_record.active_fork()?.created_at_ms, args.updated_at_ms)?; enforce_fork_cooldown(old_record.active_fork()?.created_at_ms, args.updated_at_ms)?;
} }
@@ -664,12 +670,8 @@ fn process_update_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args:
signature: [0; 64], signature: [0; 64],
}; };
let record_signer = if root_changed { let record_signer = if let Some(new_key) = appended_key {
new_record.root_key
} else if let Some(new_key) = appended_key {
new_key new_key
} else if args.auth_mode == AUTH_MODE_ROOT {
new_record.root_key
} else { } else {
new_record.active_fork()?.blockchain_key new_record.active_fork()?.blockchain_key
}; };