SHA256
Compare commits
3
Commits
main
...
смена-ключей
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
9be4c4d3a5 | ||
|
|
f5698771e4 | ||
|
|
151a2c1754 |
+84
-3
@@ -3,7 +3,9 @@ package server.archive;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -14,6 +16,7 @@ public final class ArweaveBlockPublisherService {
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||
private final KeyRotationCandidateBlocksDAO candidateBlocksDAO = KeyRotationCandidateBlocksDAO.getInstance();
|
||||
private final ArweaveL1Uploader arweaveUploader;
|
||||
private final TurboDataItemUploader turboUploader;
|
||||
|
||||
@@ -25,13 +28,57 @@ public final class ArweaveBlockPublisherService {
|
||||
|
||||
public int runCycle() throws Exception {
|
||||
if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0;
|
||||
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
|
||||
if (candidates.isEmpty()) return 0;
|
||||
return switch (cfg.publishMode()) {
|
||||
|
||||
int maxItems = cfg.publishMaxItems();
|
||||
int attempted = 0;
|
||||
int published = 0;
|
||||
|
||||
// Ротация приоритетна: пользователь уже заблокирован для обычной записи и ждёт завершения копирования.
|
||||
List<KeyRotationCandidateBlockEntry> rotationCandidates = candidateBlocksDAO.listPendingArweave(maxItems);
|
||||
if (!rotationCandidates.isEmpty()) {
|
||||
attempted += rotationCandidates.size();
|
||||
published += switch (cfg.publishMode()) {
|
||||
case TURBO -> publishTurboRotation(rotationCandidates);
|
||||
case ARWEAVE -> publishDirectArweaveRotation(rotationCandidates);
|
||||
case NONE -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
int remaining = Math.max(0, maxItems - attempted);
|
||||
if (remaining == 0) return published;
|
||||
|
||||
List<BlockEntry> candidates = blocksDAO.listPendingArweave(remaining);
|
||||
if (candidates.isEmpty()) return published;
|
||||
published += switch (cfg.publishMode()) {
|
||||
case TURBO -> publishTurbo(candidates);
|
||||
case ARWEAVE -> publishDirectArweave(candidates);
|
||||
case NONE -> 0;
|
||||
};
|
||||
return published;
|
||||
}
|
||||
|
||||
private int publishTurboRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
|
||||
int published = 0;
|
||||
Exception firstFailure = null;
|
||||
for (KeyRotationCandidateBlockEntry e : candidates) {
|
||||
byte[] raw = e.getBlockBytes();
|
||||
byte[] id = e.getDataItemId();
|
||||
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||
try {
|
||||
TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id);
|
||||
candidateBlocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis());
|
||||
published++;
|
||||
log.debug("Turbo published key-rotation DataItem {} chain={} block={}",
|
||||
result.dataItemId(), e.getCandidateBlockchainName(), e.getBlockNumber());
|
||||
} catch (Exception ex) {
|
||||
if (firstFailure == null) firstFailure = ex;
|
||||
log.warn("Turbo key-rotation publish failed: chain={} block={} bytes={} error={}",
|
||||
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length, ex.getMessage());
|
||||
}
|
||||
}
|
||||
if (published > 0) log.info("Published {} SHiNE key-rotation DataItems through Turbo", published);
|
||||
if (published == 0 && firstFailure != null) throw firstFailure;
|
||||
return published;
|
||||
}
|
||||
|
||||
private int publishTurbo(List<BlockEntry> candidates) throws Exception {
|
||||
@@ -57,6 +104,40 @@ public final class ArweaveBlockPublisherService {
|
||||
return published;
|
||||
}
|
||||
|
||||
private int publishDirectArweaveRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
|
||||
List<byte[]> items = new ArrayList<>();
|
||||
List<byte[]> ids = new ArrayList<>();
|
||||
long estimated = 32;
|
||||
for (KeyRotationCandidateBlockEntry e : candidates) {
|
||||
byte[] raw = e.getBlockBytes();
|
||||
byte[] id = e.getDataItemId();
|
||||
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||
long next = estimated + 64L + raw.length;
|
||||
if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break;
|
||||
if (next > cfg.publishMaxBundleBytes()) {
|
||||
log.error("One key-rotation DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}",
|
||||
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length);
|
||||
continue;
|
||||
}
|
||||
items.add(raw);
|
||||
ids.add(id);
|
||||
estimated = next;
|
||||
}
|
||||
if (items.isEmpty()) return 0;
|
||||
|
||||
byte[] bundle = Ans104Bundle.write(items);
|
||||
List<ArweaveL1Uploader.Tag> rootTags = List.of(
|
||||
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
|
||||
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
|
||||
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0")
|
||||
);
|
||||
ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags);
|
||||
candidateBlocksDAO.markArweavePublished(ids, System.currentTimeMillis());
|
||||
log.info("Published {} SHiNE key-rotation DataItems in direct Arweave root tx {} (bundle={} bytes)",
|
||||
items.size(), result.txId(), bundle.length);
|
||||
return items.size();
|
||||
}
|
||||
|
||||
private int publishDirectArweave(List<BlockEntry> candidates) throws Exception {
|
||||
List<byte[]> items = new ArrayList<>();
|
||||
List<byte[]> ids = new ArrayList<>();
|
||||
|
||||
@@ -25,6 +25,10 @@ public final class BodyRecordParser {
|
||||
&& (v == (CreateChannelBody.VER & 0xFFFF))) {
|
||||
return new CreateChannelBody(subType, version, bodyBytes).check();
|
||||
}
|
||||
if (st == (ForkBody.SUBTYPE & 0xFFFF)
|
||||
&& (v == (ForkBody.VER & 0xFFFF))) {
|
||||
return new ForkBody(subType, version, bodyBytes).check();
|
||||
}
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
|
||||
);
|
||||
|
||||
@@ -35,6 +35,8 @@ public final class MsgSubType {
|
||||
/** HeaderBody: subType всегда 0 (compat). */
|
||||
public static final short HEADER_COMPAT = 0;
|
||||
public static final short TECH_CREATE_CHANNEL = 1;
|
||||
/** Новый fork/ротация ключей: ссылка на родительскую цепочку и точку отката. */
|
||||
public static final short TECH_FORK = 2;
|
||||
|
||||
/* ===================== TEXT (msg_type=1) ===================== */
|
||||
|
||||
@@ -53,7 +55,7 @@ public final class MsgSubType {
|
||||
|
||||
/**
|
||||
* REPLY — ответ на сообщение.
|
||||
* НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32).
|
||||
* НЕ в линии. Имеет target (toLogin + blockNumber + hash32).
|
||||
* Может указывать на чужой блокчейн/чужую линию/чужой канал.
|
||||
*/
|
||||
public static final short TEXT_REPLY = 20;
|
||||
@@ -69,7 +71,7 @@ public final class MsgSubType {
|
||||
|
||||
/**
|
||||
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
|
||||
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||
* Имеет hasLine + target (toLogin + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||
*/
|
||||
public static final short TEXT_REPOST = 50;
|
||||
|
||||
|
||||
+18
-17
@@ -1,31 +1,32 @@
|
||||
package blockchain.body;
|
||||
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
/**
|
||||
* BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
|
||||
* BodyHasTarget — дополнительный интерфейс для body, которые ссылаются на логическую цель.
|
||||
*
|
||||
* Новое правило:
|
||||
* - toLogin НЕ храним в байтах блока.
|
||||
* - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN".
|
||||
* Актуальное правило target:
|
||||
* - в подписываемых байтах хранится login цели;
|
||||
* - номер fork/blockchainName в target не хранится;
|
||||
* - идентичность цели задаётся как login + blockNumber + blockHash.
|
||||
*
|
||||
* Все методы могут возвращать null.
|
||||
* Это позволяет одной и той же логической записи сохранять ссылки после fork,
|
||||
* если её номер и SHA-256 hash остались прежними.
|
||||
*/
|
||||
public interface BodyHasTarget {
|
||||
|
||||
/** login цели (nullable). Вычисляется из toBchName(). */
|
||||
default String toLogin() {
|
||||
String bch = toBchName();
|
||||
if (bch == null) return null;
|
||||
return BlockchainNameUtil.loginFromBlockchainName(bch);
|
||||
}
|
||||
/** login цели. Актуальные runtime-body обязаны переопределять этот метод. */
|
||||
default String toLogin() { return null; }
|
||||
|
||||
/** blockchainName цели (nullable). */
|
||||
String toBchName();
|
||||
/**
|
||||
* Legacy source-level accessor. В новом подписанном target blockchainName не хранится.
|
||||
* Оставлен временно только чтобы старые вспомогательные классы компилировались;
|
||||
* runtime-логика не должна использовать его для новых блоков.
|
||||
*/
|
||||
@Deprecated
|
||||
default String toBchName() { return null; }
|
||||
|
||||
/** globalNumber цели (nullable). */
|
||||
/** globalNumber цели (nullable только если конкретный subtype не содержит target). */
|
||||
Integer toBlockGlobalNumber();
|
||||
|
||||
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
|
||||
byte[] toBlockHashBytes();
|
||||
}
|
||||
}
|
||||
|
||||
+61
-158
@@ -1,7 +1,6 @@
|
||||
package blockchain.body;
|
||||
|
||||
import blockchain.MsgSubType;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
@@ -12,96 +11,38 @@ import java.util.Objects;
|
||||
/**
|
||||
* ConnectionBody — type=3, ver=1 (в заголовке блока).
|
||||
*
|
||||
* subType (в заголовке блока) как MsgSubType:
|
||||
* FRIEND=10, UNFRIEND=11
|
||||
* CONTACT=20, UNCONTACT=21
|
||||
* FOLLOW=30, UNFOLLOW=31
|
||||
* SPOUSE=40, UNSPOUSE=41
|
||||
* PARENT=50, UNPARENT=51
|
||||
* CHILD=52, UNCHILD=53
|
||||
* SIBLING=54, UNSIBLING=55
|
||||
* FRIEND=14, UNFRIEND=15
|
||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
|
||||
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
|
||||
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
|
||||
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
||||
*
|
||||
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
||||
* bodyBytes (BigEndian):
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
*
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [1] toLoginLen (uint8)
|
||||
* [N] toLogin UTF-8
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32 (raw 32 bytes)
|
||||
*
|
||||
* toLogin вычисляется автоматически из toBlockchainName:
|
||||
* toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName)
|
||||
* Номер fork/blockchainName в target не хранится.
|
||||
*/
|
||||
|
||||
/**
|
||||
* =========================================================================
|
||||
* ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов)
|
||||
* =========================================================================
|
||||
*
|
||||
* Термины:
|
||||
* - ROOT линии/канала = блок, который "начинает" линию:
|
||||
* * для канала "0" root = HEADER (blockNumber=0)
|
||||
* * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока)
|
||||
*
|
||||
* 1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*):
|
||||
* FRIEND / CONTACT -> цель ВСЕГДА HEADER пользователя:
|
||||
* toBlockNumber = 0
|
||||
* toBlockHash32 = hash32(HEADER цели)
|
||||
*
|
||||
* 2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE):
|
||||
* FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER):
|
||||
* toBlockNumber = 0
|
||||
* toBlockHash32 = hash32(HEADER цели)
|
||||
*
|
||||
* FOLLOW/подписка на конкретный канал пользователя ->
|
||||
* цель = ROOT этого канала:
|
||||
* - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER)
|
||||
* - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL),
|
||||
* toBlockHash32=hash32(CREATE_CHANNEL)
|
||||
*
|
||||
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
||||
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
||||
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
||||
*
|
||||
* Зачем так:
|
||||
* - связи и подписки всегда стабильны и не ломаются при новых постах,
|
||||
* - один понятный инвариант: "подписка всегда указывает на root линии".
|
||||
* =========================================================================
|
||||
*/
|
||||
|
||||
public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
|
||||
|
||||
public static final short TYPE = 3;
|
||||
public static final short VER = 1;
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType; // из header
|
||||
public final short version; // из header
|
||||
public final short subType;
|
||||
public final short version;
|
||||
|
||||
// line
|
||||
public final int lineCode;
|
||||
public final int prevLineNumber;
|
||||
public final byte[] prevLineHash32;
|
||||
public final int thisLineNumber;
|
||||
|
||||
// payload
|
||||
public final String toBlockchainName;
|
||||
public final String toLogin;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32;
|
||||
|
||||
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
@@ -111,34 +52,25 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
if (!isValidSubType(this.subType)) {
|
||||
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
|
||||
}
|
||||
|
||||
// минимум:
|
||||
// lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32]
|
||||
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) {
|
||||
if (bodyBytes.length < 4 + 4 + 32 + 4 + 1 + 1 + 4 + 32) {
|
||||
throw new IllegalArgumentException("ConnectionBody too short");
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
this.lineCode = bb.getInt();
|
||||
|
||||
this.prevLineNumber = bb.getInt();
|
||||
|
||||
this.prevLineHash32 = new byte[32];
|
||||
bb.get(this.prevLineHash32);
|
||||
|
||||
this.thisLineNumber = bb.getInt();
|
||||
|
||||
int bchLen = Byte.toUnsignedInt(bb.get());
|
||||
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
||||
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
|
||||
|
||||
byte[] bchBytes = new byte[bchLen];
|
||||
bb.get(bchBytes);
|
||||
this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8);
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
|
||||
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
|
||||
@@ -150,39 +82,68 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
byte[] prevLineHash32,
|
||||
int thisLineNumber,
|
||||
short subType,
|
||||
String toBlockchainName,
|
||||
String toLogin,
|
||||
int toBlockGlobalNumber,
|
||||
byte[] toBlockHash32) {
|
||||
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
||||
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
// Железное правило формата: bchName -> login + "-NNN"
|
||||
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) {
|
||||
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
|
||||
}
|
||||
|
||||
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.lineCode = lineCode;
|
||||
|
||||
this.prevLineNumber = prevLineNumber;
|
||||
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
this.prevLineHash32 = prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32);
|
||||
this.thisLineNumber = thisLineNumber;
|
||||
|
||||
this.subType = subType;
|
||||
this.version = VER;
|
||||
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionBody check() {
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
||||
|
||||
if (prevLineNumber == -1) {
|
||||
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
|
||||
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
|
||||
} else if (prevLineHash32 == null || prevLineHash32.length != 32) {
|
||||
throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||
}
|
||||
|
||||
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
if (loginBytes.length == 0 || loginBytes.length > 255)
|
||||
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
int cap = 4 + 4 + 32 + 4 + 1 + loginBytes.length + 4 + 32;
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.putInt(lineCode);
|
||||
bb.putInt(prevLineNumber);
|
||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
bb.putInt(thisLineNumber);
|
||||
bb.put((byte) loginBytes.length);
|
||||
bb.put(loginBytes);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
private static boolean isValidSubType(short st) {
|
||||
int v = st & 0xFFFF;
|
||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
@@ -211,76 +172,18 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionBody check() {
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
||||
|
||||
// line rule (как было)
|
||||
if (prevLineNumber == -1) {
|
||||
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
|
||||
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
|
||||
} else {
|
||||
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||
}
|
||||
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
|
||||
// гарантируем вычислимый toLogin (иначе target “битый” по стандарту)
|
||||
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null)
|
||||
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
|
||||
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (bchBytes.length == 0 || bchBytes.length > 255)
|
||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
||||
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
int cap = 4 + (4 + 32 + 4)
|
||||
+ 1 + bchBytes.length
|
||||
+ 4 + 32;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
bb.putInt(lineCode);
|
||||
|
||||
bb.putInt(prevLineNumber);
|
||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
bb.putInt(thisLineNumber);
|
||||
|
||||
bb.put((byte) bchBytes.length);
|
||||
bb.put(bchBytes);
|
||||
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
private static boolean isAllZero32(byte[] b) {
|
||||
if (b == null || b.length != 32) return true;
|
||||
for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
|
||||
for (byte value : b) if (value != 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ====================== BodyHasLine ====================== */
|
||||
@Override public int lineCode() { return lineCode; }
|
||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
||||
@Override public int lineSeq() { return thisLineNumber; }
|
||||
|
||||
/* ====================== BodyHasTarget ===================== */
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public String toLogin() { return toLogin; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package blockchain.body;
|
||||
|
||||
import blockchain.MsgSubType;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* TECH_FORK body (type=0, subType=2, version=1).
|
||||
*
|
||||
* Записывается первым новым блоком после точной перепубликации выбранного
|
||||
* префикса старой цепочки новым blockchain key.
|
||||
*
|
||||
* body bytes (BigEndian):
|
||||
* [32] parentBlockchainKey
|
||||
* [4] forkPointBlockNumber
|
||||
* [32] forkPointBlockHash32
|
||||
* [8] forkPointTimestampMs
|
||||
* [4] parentTipBlockNumber
|
||||
* [32] parentTipBlockHash32
|
||||
* [8] parentTipTimestampMs
|
||||
* [4] discardedBlocksCount
|
||||
* [1] reasonCode
|
||||
* [2] commentUtf8Length
|
||||
* [N] comment UTF-8 (0..1024 bytes)
|
||||
*/
|
||||
public final class ForkBody implements BodyRecord {
|
||||
|
||||
public static final short TYPE = 0;
|
||||
public static final short VER = 1;
|
||||
public static final short SUBTYPE = MsgSubType.TECH_FORK;
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public static final int REASON_ROUTINE_ROTATION = 1;
|
||||
public static final int REASON_POSSIBLE_COMPROMISE = 2;
|
||||
public static final int REASON_CONFIRMED_COMPROMISE_ROLLBACK = 3;
|
||||
public static final int REASON_RECOVERY = 4;
|
||||
|
||||
public static final int MAX_COMMENT_UTF8_LEN = 1024;
|
||||
private static final int FIXED_LEN = 32 + 4 + 32 + 8 + 4 + 32 + 8 + 4 + 1 + 2;
|
||||
|
||||
public final short subType;
|
||||
public final short version;
|
||||
public final byte[] parentBlockchainKey32;
|
||||
public final int forkPointBlockNumber;
|
||||
public final byte[] forkPointBlockHash32;
|
||||
public final long forkPointTimestampMs;
|
||||
public final int parentTipBlockNumber;
|
||||
public final byte[] parentTipBlockHash32;
|
||||
public final long parentTipTimestampMs;
|
||||
public final int discardedBlocksCount;
|
||||
public final int reasonCode;
|
||||
public final String comment;
|
||||
|
||||
public ForkBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)");
|
||||
}
|
||||
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("ForkBody version must be 1");
|
||||
}
|
||||
if (bodyBytes.length < FIXED_LEN) {
|
||||
throw new IllegalArgumentException("ForkBody too short");
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
this.parentBlockchainKey32 = new byte[32];
|
||||
bb.get(this.parentBlockchainKey32);
|
||||
this.forkPointBlockNumber = bb.getInt();
|
||||
this.forkPointBlockHash32 = new byte[32];
|
||||
bb.get(this.forkPointBlockHash32);
|
||||
this.forkPointTimestampMs = bb.getLong();
|
||||
this.parentTipBlockNumber = bb.getInt();
|
||||
this.parentTipBlockHash32 = new byte[32];
|
||||
bb.get(this.parentTipBlockHash32);
|
||||
this.parentTipTimestampMs = bb.getLong();
|
||||
this.discardedBlocksCount = bb.getInt();
|
||||
this.reasonCode = Byte.toUnsignedInt(bb.get());
|
||||
int commentLen = Short.toUnsignedInt(bb.getShort());
|
||||
if (commentLen > MAX_COMMENT_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024");
|
||||
}
|
||||
if (bb.remaining() != commentLen) {
|
||||
throw new IllegalArgumentException("ForkBody tail mismatch: remaining=" + bb.remaining() + " commentLen=" + commentLen);
|
||||
}
|
||||
byte[] commentBytes = new byte[commentLen];
|
||||
bb.get(commentBytes);
|
||||
this.comment = new String(commentBytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public ForkBody(byte[] parentBlockchainKey32,
|
||||
int forkPointBlockNumber,
|
||||
byte[] forkPointBlockHash32,
|
||||
long forkPointTimestampMs,
|
||||
int parentTipBlockNumber,
|
||||
byte[] parentTipBlockHash32,
|
||||
long parentTipTimestampMs,
|
||||
int discardedBlocksCount,
|
||||
int reasonCode,
|
||||
String comment) {
|
||||
this.subType = SUBTYPE;
|
||||
this.version = VER;
|
||||
this.parentBlockchainKey32 = copy32(parentBlockchainKey32, "parentBlockchainKey32");
|
||||
this.forkPointBlockNumber = forkPointBlockNumber;
|
||||
this.forkPointBlockHash32 = copy32(forkPointBlockHash32, "forkPointBlockHash32");
|
||||
this.forkPointTimestampMs = forkPointTimestampMs;
|
||||
this.parentTipBlockNumber = parentTipBlockNumber;
|
||||
this.parentTipBlockHash32 = copy32(parentTipBlockHash32, "parentTipBlockHash32");
|
||||
this.parentTipTimestampMs = parentTipTimestampMs;
|
||||
this.discardedBlocksCount = discardedBlocksCount;
|
||||
this.reasonCode = reasonCode;
|
||||
this.comment = normalizeComment(comment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ForkBody check() {
|
||||
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)");
|
||||
}
|
||||
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("ForkBody version must be 1");
|
||||
}
|
||||
require32(parentBlockchainKey32, "parentBlockchainKey32");
|
||||
require32(forkPointBlockHash32, "forkPointBlockHash32");
|
||||
require32(parentTipBlockHash32, "parentTipBlockHash32");
|
||||
if (forkPointBlockNumber < 0) throw new IllegalArgumentException("forkPointBlockNumber < 0");
|
||||
if (parentTipBlockNumber < forkPointBlockNumber) {
|
||||
throw new IllegalArgumentException("parentTipBlockNumber < forkPointBlockNumber");
|
||||
}
|
||||
if (forkPointTimestampMs < 0) throw new IllegalArgumentException("forkPointTimestampMs < 0");
|
||||
if (parentTipTimestampMs < 0) throw new IllegalArgumentException("parentTipTimestampMs < 0");
|
||||
int expectedDiscarded = parentTipBlockNumber - forkPointBlockNumber;
|
||||
if (discardedBlocksCount != expectedDiscarded) {
|
||||
throw new IllegalArgumentException("discardedBlocksCount must equal parentTipBlockNumber - forkPointBlockNumber");
|
||||
}
|
||||
if (!isReasonSupported(reasonCode)) {
|
||||
throw new IllegalArgumentException("Unsupported fork reasonCode=" + reasonCode);
|
||||
}
|
||||
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
|
||||
if (commentUtf8.length > MAX_COMMENT_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
check();
|
||||
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
|
||||
ByteBuffer bb = ByteBuffer.allocate(FIXED_LEN + commentUtf8.length).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put(parentBlockchainKey32);
|
||||
bb.putInt(forkPointBlockNumber);
|
||||
bb.put(forkPointBlockHash32);
|
||||
bb.putLong(forkPointTimestampMs);
|
||||
bb.putInt(parentTipBlockNumber);
|
||||
bb.put(parentTipBlockHash32);
|
||||
bb.putLong(parentTipTimestampMs);
|
||||
bb.putInt(discardedBlocksCount);
|
||||
bb.put((byte) reasonCode);
|
||||
bb.putShort((short) commentUtf8.length);
|
||||
bb.put(commentUtf8);
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
public static boolean isReasonSupported(int code) {
|
||||
return code == REASON_ROUTINE_ROTATION
|
||||
|| code == REASON_POSSIBLE_COMPROMISE
|
||||
|| code == REASON_CONFIRMED_COMPROMISE_ROLLBACK
|
||||
|| code == REASON_RECOVERY;
|
||||
}
|
||||
|
||||
private static byte[] copy32(byte[] value, String name) {
|
||||
require32(value, name);
|
||||
return Arrays.copyOf(value, 32);
|
||||
}
|
||||
|
||||
private static void require32(byte[] value, String name) {
|
||||
if (value == null || value.length != 32) {
|
||||
throw new IllegalArgumentException(name + " must be 32 bytes");
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeComment(String value) {
|
||||
if (value == null) return "";
|
||||
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||
}
|
||||
}
|
||||
+26
-35
@@ -15,12 +15,13 @@ import java.util.Objects;
|
||||
* 1 = LIKE
|
||||
* 2 = UNLIKE
|
||||
*
|
||||
* bodyBytes (BigEndian), новый формат:
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* bodyBytes (BigEndian):
|
||||
* [1] toLoginLen (uint8)
|
||||
* [N] toLogin UTF-8
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32 (raw 32 bytes)
|
||||
*
|
||||
* Номер fork/blockchainName в target не хранится.
|
||||
* ЛИНИИ НЕТ.
|
||||
*/
|
||||
public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||
@@ -30,10 +31,10 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType; // из header
|
||||
public final short version; // из header
|
||||
public final short subType;
|
||||
public final short version;
|
||||
|
||||
public final String toBlockchainName;
|
||||
public final String toLogin;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32;
|
||||
|
||||
@@ -49,40 +50,37 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||
if (!isSupportedSubType(this.subType)) {
|
||||
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
|
||||
}
|
||||
|
||||
// минимум: nameLen[1]+name[1]+global[4]+hash[32]
|
||||
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
||||
if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
|
||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
}
|
||||
|
||||
public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
public ReactionBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
|
||||
this.subType = MsgSubType.REACTION_LIKE;
|
||||
this.version = VER;
|
||||
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
}
|
||||
@@ -91,37 +89,30 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||
public ReactionBody check() {
|
||||
if (!isSupportedSubType(subType))
|
||||
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
||||
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
if (toLogin == null || toLogin.isBlank())
|
||||
throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameBytes.length == 0 || nameBytes.length > 255)
|
||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
||||
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
if (loginBytes.length == 0 || loginBytes.length > 255)
|
||||
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
|
||||
|
||||
int cap = 1 + nameBytes.length + 4 + 32;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) nameBytes.length);
|
||||
bb.put(nameBytes);
|
||||
ByteBuffer bb = ByteBuffer.allocate(1 + loginBytes.length + 4 + 32).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) loginBytes.length);
|
||||
bb.put(loginBytes);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
/* ====================== BodyHasTarget ====================== */
|
||||
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public String toLogin() { return toLogin; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
|
||||
+24
-35
@@ -13,11 +13,11 @@ import java.util.Objects;
|
||||
/**
|
||||
* StatusActionBody — type=5, ver=1.
|
||||
*
|
||||
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
|
||||
* Все STATUS_ACTION имеют target на конкретный логический блок и опциональный текст-пояснение.
|
||||
*
|
||||
* Формат bodyBytes (BigEndian):
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [1] toLoginLen (uint8)
|
||||
* [N] toLogin UTF-8
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
@@ -31,7 +31,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
public final short subType;
|
||||
public final short version;
|
||||
public final String toBlockchainName;
|
||||
public final String toLogin;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32;
|
||||
public final String message;
|
||||
@@ -51,33 +51,32 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toLoginLen is 0");
|
||||
ensureMin(bb, loginLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
|
||||
|
||||
ensureNoTail(bb, "StatusActionBody");
|
||||
}
|
||||
|
||||
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
public StatusActionBody(short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = VER;
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
this.message = message;
|
||||
@@ -85,16 +84,10 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
@Override
|
||||
public StatusActionBody check() {
|
||||
if (!isSupportedSubType(subType)) {
|
||||
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||
}
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
|
||||
}
|
||||
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("STATUS_ACTION toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
}
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
if (message == null) throw new IllegalArgumentException("message is null");
|
||||
return this;
|
||||
}
|
||||
@@ -104,15 +97,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
|
||||
}
|
||||
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
||||
throw new IllegalArgumentException("STATUS_ACTION toLogin utf8 len must be 1..255");
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||
.order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.put((byte) loginUtf8.length);
|
||||
bb.put(loginUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
@@ -120,7 +112,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public String toLogin() { return toLogin; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
@@ -141,14 +133,11 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len == 0) return "";
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
bb.get(bytes);
|
||||
|
||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
} catch (CharacterCodingException e) {
|
||||
|
||||
+36
-62
@@ -33,23 +33,13 @@ import java.util.Objects;
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*
|
||||
* EDIT_POST:
|
||||
* EDIT_POST / REPOST:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*
|
||||
* REPOST:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [1] toLoginLen (uint8)
|
||||
* [N] toLogin UTF-8
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
@@ -72,7 +62,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
public final int thisLineNumber;
|
||||
|
||||
// target (для EDIT_POST / REPOST)
|
||||
public final String toBlockchainName; // nullable для POST/EDIT_POST
|
||||
public final String toLogin; // nullable для сообщений без target
|
||||
public final Integer toBlockGlobalNumber; // nullable для POST
|
||||
public final byte[] toBlockHash32; // nullable для POST
|
||||
|
||||
@@ -116,29 +106,19 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
|
||||
this.thisLineNumber = bb.getInt();
|
||||
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
// нужен target
|
||||
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target");
|
||||
int tgtNum = bb.getInt();
|
||||
byte[] tgtHash = new byte[32];
|
||||
bb.get(tgtHash);
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = tgtNum;
|
||||
this.toBlockHash32 = tgtHash;
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPOST missing target");
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("REPOST toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPOST payload too short");
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TEXT target missing");
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||
ensureMin(bb, loginLen + 4 + 32 + 2, "TEXT target payload too short");
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
} else {
|
||||
this.toBlockchainName = null;
|
||||
this.toLogin = null;
|
||||
this.toBlockGlobalNumber = null;
|
||||
this.toBlockHash32 = null;
|
||||
}
|
||||
@@ -158,7 +138,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
short subType,
|
||||
Integer toBlockGlobalNumber,
|
||||
byte[] toBlockHash32,
|
||||
String toBlockchainName,
|
||||
String toLogin,
|
||||
String message) {
|
||||
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
@@ -189,27 +169,29 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
this.thisLineNumber = thisLineNumber;
|
||||
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
} else {
|
||||
this.toBlockchainName = null;
|
||||
this.toLogin = null;
|
||||
this.toBlockGlobalNumber = null;
|
||||
this.toBlockHash32 = null;
|
||||
}
|
||||
@@ -240,13 +222,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
||||
if (toBlockchainName != null)
|
||||
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName");
|
||||
if (toLogin == null || toLogin.isBlank())
|
||||
throw new IllegalArgumentException("EDIT_POST toLogin is blank");
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("REPOST message is blank");
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("REPOST toBlockchainName is blank");
|
||||
if (toLogin == null || toLogin.isBlank())
|
||||
throw new IllegalArgumentException("REPOST toLogin is blank");
|
||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
@@ -259,7 +241,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
} else if (message == null) {
|
||||
throw new IllegalArgumentException("Text message is null");
|
||||
}
|
||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||
if (toLogin != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
||||
}
|
||||
|
||||
@@ -284,19 +266,14 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
// EDIT_POST
|
||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
|
||||
cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
|
||||
} else {
|
||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName");
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (toLogin == null) throw new IllegalArgumentException("target missing toLogin");
|
||||
byte[] nameUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||
throw new IllegalArgumentException("REPOST toBlockchainName utf8 len must be 1..255");
|
||||
throw new IllegalArgumentException("target toLogin utf8 len must be 1..255");
|
||||
}
|
||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPOST missing toBlockGlobalNumber");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32");
|
||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("target missing toBlockGlobalNumber");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("target toBlockHash32 != 32");
|
||||
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
|
||||
}
|
||||
|
||||
@@ -307,13 +284,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
bb.putInt(thisLineNumber);
|
||||
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
bb.put((byte) loginUtf8.length);
|
||||
bb.put(loginUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
}
|
||||
@@ -331,7 +305,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
@Override public int lineSeq() { return thisLineNumber; }
|
||||
|
||||
/* ====================== BodyHasTarget ===================== */
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public String toLogin() { return toLogin; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
|
||||
+25
-38
@@ -11,14 +11,11 @@ import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* TextRatingBody — type=1, ver=1.
|
||||
*
|
||||
* subType:
|
||||
* - RATING (30)
|
||||
* TextRatingBody — type=1, ver=1, subType=30.
|
||||
*
|
||||
* Формат bodyBytes (BigEndian):
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [1] toLoginLen (uint8)
|
||||
* [N] toLogin UTF-8
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
@@ -32,7 +29,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
public final short subType;
|
||||
public final short version;
|
||||
public final String toBlockchainName;
|
||||
public final String toLogin;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32;
|
||||
public final String message;
|
||||
@@ -52,33 +49,32 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0) throw new IllegalArgumentException("RATING toLoginLen is 0");
|
||||
ensureMin(bb, loginLen + 4 + 32 + 2, "RATING payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
|
||||
|
||||
ensureNoTail(bb, "TextRatingBody");
|
||||
}
|
||||
|
||||
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
public TextRatingBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||
|
||||
this.subType = MsgSubType.TEXT_RATING;
|
||||
this.version = VER;
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
this.message = message;
|
||||
@@ -86,16 +82,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
@Override
|
||||
public TextRatingBody check() {
|
||||
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
|
||||
}
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
||||
throw new IllegalArgumentException("RATING toBlockchainName is blank");
|
||||
}
|
||||
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF))
|
||||
throw new IllegalArgumentException("Bad RATING subType");
|
||||
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("RATING toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
}
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||
return this;
|
||||
}
|
||||
@@ -106,15 +97,14 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
|
||||
}
|
||||
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
||||
throw new IllegalArgumentException("RATING toLogin utf8 len must be 1..255");
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||
.order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.put((byte) loginUtf8.length);
|
||||
bb.put(loginUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
@@ -122,7 +112,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public String toLogin() { return toLogin; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
@@ -130,14 +120,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
bb.get(bytes);
|
||||
|
||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||
|
||||
+36
-108
@@ -17,84 +17,56 @@ import java.util.Objects;
|
||||
* - REPLY (20)
|
||||
* - EDIT_REPLY (21)
|
||||
*
|
||||
* Форматы bodyBytes (BigEndian):
|
||||
*
|
||||
* REPLY:
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* Оба subtype используют одинаковый target-формат:
|
||||
* [1] toLoginLen (uint8)
|
||||
* [N] toLogin UTF-8
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [M] text UTF-8
|
||||
*
|
||||
* EDIT_REPLY:
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
* Для EDIT_REPLY text может быть пустым (логическое удаление).
|
||||
*/
|
||||
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
public static final short TYPE = 1;
|
||||
public static final short VER = 1;
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType; // из header
|
||||
public final short version; // (=1)
|
||||
public final short subType;
|
||||
public final short version;
|
||||
|
||||
// target
|
||||
public final String toBlockchainName; // nullable для EDIT_REPLY
|
||||
public final String toLogin;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32; // 32
|
||||
|
||||
// text
|
||||
public final byte[] toBlockHash32;
|
||||
public final String message;
|
||||
|
||||
public TextReplyBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||
}
|
||||
|
||||
int st = this.subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TextReplyBody too short");
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
// минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2]
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0) throw new IllegalArgumentException("TextReplyBody toLoginLen is 0");
|
||||
ensureMin(bb, loginLen + 4 + 32 + 2, "TextReplyBody payload too short");
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
|
||||
} else {
|
||||
// EDIT_REPLY: target без имени
|
||||
ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
}
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
|
||||
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
||||
ensureNoTail(bb, "TextReplyBody");
|
||||
@@ -103,11 +75,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
public TextReplyBody(short subType,
|
||||
int toBlockGlobalNumber,
|
||||
byte[] toBlockHash32,
|
||||
String toBlockchainName,
|
||||
String toLogin,
|
||||
String message) {
|
||||
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||
@@ -116,25 +88,15 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
|
||||
throw new IllegalArgumentException("message is blank");
|
||||
}
|
||||
|
||||
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
} else {
|
||||
// EDIT_REPLY: имя не хранить
|
||||
this.toBlockchainName = null;
|
||||
}
|
||||
|
||||
this.subType = subType;
|
||||
this.version = VER;
|
||||
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
@@ -143,23 +105,14 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
||||
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
||||
|
||||
if (toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
|
||||
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
|
||||
} else {
|
||||
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
|
||||
if (toBlockchainName != null)
|
||||
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
|
||||
if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank");
|
||||
} else if (message == null) {
|
||||
throw new IllegalArgumentException("EDIT_REPLY message is null");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -167,47 +120,27 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
public byte[] toBytes() {
|
||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
|
||||
throw new IllegalArgumentException("Text payload is empty");
|
||||
}
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
||||
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
||||
throw new IllegalArgumentException("TextReplyBody toLogin utf8 len must be 1..255");
|
||||
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255)
|
||||
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
|
||||
|
||||
int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
// EDIT_REPLY
|
||||
int cap = (4 + 32) + 2 + msgUtf8.length;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||
.order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) loginUtf8.length);
|
||||
bb.put(loginUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
/* ====================== BodyHasTarget ====================== */
|
||||
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public String toLogin() { return toLogin; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
@@ -215,8 +148,6 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
||||
}
|
||||
|
||||
/* ====================== helpers ====================== */
|
||||
|
||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len == 0) {
|
||||
@@ -224,14 +155,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
throw new IllegalArgumentException(fieldName + " is empty");
|
||||
}
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
bb.get(bytes);
|
||||
|
||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package blockchain;
|
||||
|
||||
import blockchain.body.ForkBody;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class ForkBodyTest {
|
||||
|
||||
@Test
|
||||
void roundTripThroughBodyRecordParser() {
|
||||
byte[] parentKey = filled(32, 0x11);
|
||||
byte[] forkHash = filled(32, 0x22);
|
||||
byte[] tipHash = filled(32, 0x33);
|
||||
|
||||
ForkBody source = new ForkBody(
|
||||
parentKey,
|
||||
120,
|
||||
forkHash,
|
||||
1_700_000_000_000L,
|
||||
137,
|
||||
tipHash,
|
||||
1_700_100_000_000L,
|
||||
17,
|
||||
ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK,
|
||||
"Удаляю неизвестные записи"
|
||||
).check();
|
||||
|
||||
ForkBody parsed = assertInstanceOf(
|
||||
ForkBody.class,
|
||||
BodyRecordParser.parse(ForkBody.TYPE, ForkBody.SUBTYPE, ForkBody.VER, source.toBytes())
|
||||
);
|
||||
|
||||
assertArrayEquals(parentKey, parsed.parentBlockchainKey32);
|
||||
assertEquals(120, parsed.forkPointBlockNumber);
|
||||
assertArrayEquals(forkHash, parsed.forkPointBlockHash32);
|
||||
assertEquals(137, parsed.parentTipBlockNumber);
|
||||
assertArrayEquals(tipHash, parsed.parentTipBlockHash32);
|
||||
assertEquals(17, parsed.discardedBlocksCount);
|
||||
assertEquals(ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK, parsed.reasonCode);
|
||||
assertEquals("Удаляю неизвестные записи", parsed.comment);
|
||||
}
|
||||
|
||||
@Test
|
||||
void routineRotationMayDiscardNothing() {
|
||||
ForkBody body = new ForkBody(
|
||||
filled(32, 1),
|
||||
10,
|
||||
filled(32, 2),
|
||||
1000,
|
||||
10,
|
||||
filled(32, 2),
|
||||
1000,
|
||||
0,
|
||||
ForkBody.REASON_ROUTINE_ROTATION,
|
||||
""
|
||||
);
|
||||
assertDoesNotThrow(body::check);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedWhenDiscardCountDoesNotMatchForkPointAndTip() {
|
||||
ForkBody body = new ForkBody(
|
||||
filled(32, 1),
|
||||
10,
|
||||
filled(32, 2),
|
||||
1000,
|
||||
12,
|
||||
filled(32, 3),
|
||||
2000,
|
||||
99,
|
||||
ForkBody.REASON_POSSIBLE_COMPROMISE,
|
||||
""
|
||||
);
|
||||
assertThrows(IllegalArgumentException.class, body::check);
|
||||
}
|
||||
|
||||
private static byte[] filled(int size, int value) {
|
||||
byte[] out = new byte[size];
|
||||
Arrays.fill(out, (byte) value);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package blockchain;
|
||||
|
||||
import blockchain.body.ConnectionBody;
|
||||
import blockchain.body.ReactionBody;
|
||||
import blockchain.body.StatusActionBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import blockchain.body.TextReplyBody;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class TargetLoginFormatTest {
|
||||
|
||||
private static final byte[] HASH = hash(7);
|
||||
|
||||
@Test
|
||||
void reactionRoundTripStoresLoginNotBlockchainName() {
|
||||
ReactionBody source = new ReactionBody("alice", 42, HASH);
|
||||
ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check();
|
||||
|
||||
assertEquals("alice", parsed.toLogin());
|
||||
assertEquals(42, parsed.toBlockGlobalNumber());
|
||||
assertArrayEquals(HASH, parsed.toBlockHashBytes());
|
||||
assertFalse(new String(source.toBytes()).contains("alice-001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replyAndEditReplyUseSameLoginTargetShape() {
|
||||
TextReplyBody reply = new TextReplyBody(MsgSubType.TEXT_REPLY, 11, HASH, "alice", "reply");
|
||||
TextReplyBody parsedReply = new TextReplyBody(MsgSubType.TEXT_REPLY, TextReplyBody.VER, reply.toBytes()).check();
|
||||
assertEquals("alice", parsedReply.toLogin());
|
||||
|
||||
TextReplyBody edit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, 11, HASH, "alice", "");
|
||||
TextReplyBody parsedEdit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, TextReplyBody.VER, edit.toBytes()).check();
|
||||
assertEquals("alice", parsedEdit.toLogin());
|
||||
assertEquals("", parsedEdit.message);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editPostAndConnectionCarryLoginTarget() {
|
||||
TextLineBody editPost = new TextLineBody(
|
||||
0, -1, new byte[32], -1,
|
||||
MsgSubType.TEXT_EDIT_POST,
|
||||
5, HASH, "alice", "edited"
|
||||
);
|
||||
TextLineBody parsedEditPost = new TextLineBody(MsgSubType.TEXT_EDIT_POST, TextLineBody.VER, editPost.toBytes()).check();
|
||||
assertEquals("alice", parsedEditPost.toLogin());
|
||||
|
||||
ConnectionBody connection = new ConnectionBody(
|
||||
0, -1, new byte[32], -1,
|
||||
MsgSubType.CONNECTION_FOLLOW,
|
||||
"alice", 0, HASH
|
||||
);
|
||||
ConnectionBody parsedConnection = new ConnectionBody(MsgSubType.CONNECTION_FOLLOW, ConnectionBody.VER, connection.toBytes()).check();
|
||||
assertEquals("alice", parsedConnection.toLogin());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusActionCarriesLoginTarget() {
|
||||
StatusActionBody source = new StatusActionBody(MsgSubType.STATUS_STARTED, "alice", 77, HASH, "");
|
||||
StatusActionBody parsed = new StatusActionBody(MsgSubType.STATUS_STARTED, StatusActionBody.VER, source.toBytes()).check();
|
||||
assertEquals("alice", parsed.toLogin());
|
||||
assertEquals(77, parsed.toBlockGlobalNumber());
|
||||
}
|
||||
|
||||
private static byte[] hash(int seed) {
|
||||
byte[] out = new byte[32];
|
||||
Arrays.fill(out, (byte) seed);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_23 = 23;
|
||||
public static final int SCHEMA_VERSION_24 = 24;
|
||||
public static final int SCHEMA_VERSION_25 = 25;
|
||||
public static final int SCHEMA_VERSION_26 = 26;
|
||||
public static final int SCHEMA_VERSION_27 = 27;
|
||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||
@@ -67,6 +69,8 @@ public final class DatabaseInitializer {
|
||||
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
|
||||
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
|
||||
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
|
||||
public static final String POSTGRES_MIGRATION_V26_RESOURCE = "postgres/migration_v26.sql";
|
||||
public static final String POSTGRES_MIGRATION_V27_RESOURCE = "postgres/migration_v27.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -236,6 +240,14 @@ public final class DatabaseInitializer {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_25;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_26) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V26_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_26;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_27) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V27_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_27;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+94
@@ -127,6 +127,100 @@ public final class BlockchainResyncCleanupDAO {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* После смены fork обновляет только runtime-кэш физического blockchainName у логических target-ссылок.
|
||||
* Подписанная идентичность target уже задаётся login + blockNumber + blockHash; поэтому смена cache value
|
||||
* не меняет смысл ссылки и не требует переподписывать чужие блоки.
|
||||
*/
|
||||
public void refreshLogicalTargetBlockchainCache(String login, String oldBlockchainName, String newBlockchainName) throws SQLException {
|
||||
if (login == null || login.isBlank() || newBlockchainName == null || newBlockchainName.isBlank()) {
|
||||
throw new IllegalArgumentException("login/newBlockchainName are required");
|
||||
}
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
updateTargetCacheByLogin(c, "blocks", login, newBlockchainName);
|
||||
updateTargetCacheByLogin(c, "connections_state", login, newBlockchainName);
|
||||
updateTargetCacheByLogin(c, "reactions_state", login, newBlockchainName);
|
||||
updateTargetCacheByLogin(c, "message_stats", login, newBlockchainName);
|
||||
if (oldBlockchainName != null && !oldBlockchainName.isBlank()) {
|
||||
try (PreparedStatement ps = c.prepareStatement("UPDATE message_views_state SET to_bch_name=? WHERE to_bch_name=?")) {
|
||||
ps.setString(1, newBlockchainName);
|
||||
ps.setString(2, oldBlockchainName);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("UPDATE channel_read_state SET owner_bch_name=? WHERE owner_bch_name=?")) {
|
||||
ps.setString(1, newBlockchainName);
|
||||
ps.setString(2, oldBlockchainName);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
rebuildInboundMessageCounters(c, login, newBlockchainName);
|
||||
rebuildStatsState(c);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException sql) throw sql;
|
||||
throw new SQLException("Failed to refresh logical target blockchain cache for login=" + login, e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* После replay candidate-цепочки её собственные message_stats уже существуют, но входящие реакции/ответы
|
||||
* других пользователей были созданы раньше и не проходили триггеры повторно. Поэтому пересчитываем
|
||||
* агрегаты из исходных таблиц после перепривязки runtime to_bch_name cache.
|
||||
*/
|
||||
private void rebuildInboundMessageCounters(Connection c, String login, String newBlockchainName) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE message_stats ms
|
||||
SET likes_count = (
|
||||
SELECT COUNT(*)::INTEGER
|
||||
FROM reactions_state rs
|
||||
WHERE rs.reaction_type = ?
|
||||
AND rs.last_sub_type = ?
|
||||
AND LOWER(rs.to_login) = LOWER(ms.to_login)
|
||||
AND rs.to_bch_name = ms.to_bch_name
|
||||
AND rs.to_block_number = ms.to_block_number
|
||||
AND rs.to_block_hash = ms.to_block_hash
|
||||
),
|
||||
replies_count = (
|
||||
SELECT COUNT(*)::INTEGER
|
||||
FROM blocks b
|
||||
WHERE b.msg_type = 1
|
||||
AND b.msg_sub_type = ?
|
||||
AND LOWER(b.to_login) = LOWER(ms.to_login)
|
||||
AND b.to_bch_name = ms.to_bch_name
|
||||
AND b.to_block_number = ms.to_block_number
|
||||
AND b.to_block_hash = ms.to_block_hash
|
||||
)
|
||||
WHERE LOWER(ms.to_login) = LOWER(?)
|
||||
AND ms.to_bch_name = ?
|
||||
""")) {
|
||||
ps.setInt(1, DatabaseInitializer.REACTION_LIKE);
|
||||
ps.setInt(2, DatabaseInitializer.REACTION_LIKE);
|
||||
ps.setInt(3, DatabaseInitializer.TEXT_REPLY);
|
||||
ps.setString(4, login);
|
||||
ps.setString(5, newBlockchainName);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateTargetCacheByLogin(Connection c, String table, String login, String newBlockchainName) throws SQLException {
|
||||
String sql = "UPDATE " + table + " SET to_bch_name=? WHERE LOWER(to_login)=LOWER(?) AND to_bch_name IS DISTINCT FROM ?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, newBlockchainName);
|
||||
ps.setString(2, login);
|
||||
ps.setString(3, newBlockchainName);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/** Хранилище candidate-блоков будущего fork во время ротации ключей. */
|
||||
public final class KeyRotationCandidateBlocksDAO {
|
||||
private static volatile KeyRotationCandidateBlocksDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private KeyRotationCandidateBlocksDAO() { }
|
||||
|
||||
public static KeyRotationCandidateBlocksDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (KeyRotationCandidateBlocksDAO.class) {
|
||||
if (instance == null) instance = new KeyRotationCandidateBlocksDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Идемпотентно сохраняет один candidate-блок.
|
||||
* Повтор того же blockNumber допустим только при полном совпадении hash и DataItem id.
|
||||
*/
|
||||
public KeyRotationCandidateBlockEntry insertOrGet(Connection c, KeyRotationCandidateBlockEntry entry) throws SQLException {
|
||||
KeyRotationCandidateBlockEntry existing = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber());
|
||||
if (existing != null) {
|
||||
if (Arrays.equals(existing.getBlockHash(), entry.getBlockHash())
|
||||
&& Arrays.equals(existing.getDataItemId(), entry.getDataItemId())) {
|
||||
return existing;
|
||||
}
|
||||
throw new SQLException("Candidate block conflict at number=" + entry.getBlockNumber());
|
||||
}
|
||||
|
||||
String sql = """
|
||||
INSERT INTO key_rotation_candidate_blocks (
|
||||
rotation_session_id, login, candidate_blockchain_name,
|
||||
block_number, block_hash, data_item_id, block_bytes,
|
||||
arweave_publish_pending, arweave_published_at_ms, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, NULL, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||
int i = 1;
|
||||
ps.setLong(i++, entry.getRotationSessionId());
|
||||
ps.setString(i++, entry.getLogin());
|
||||
ps.setString(i++, entry.getCandidateBlockchainName());
|
||||
ps.setInt(i++, entry.getBlockNumber());
|
||||
ps.setBytes(i++, entry.getBlockHash());
|
||||
ps.setBytes(i++, entry.getDataItemId());
|
||||
ps.setBytes(i++, entry.getBlockBytes());
|
||||
ps.setLong(i++, entry.getCreatedAtMs());
|
||||
ps.executeUpdate();
|
||||
try (ResultSet keys = ps.getGeneratedKeys()) {
|
||||
if (!keys.next()) throw new SQLException("candidate block insert returned no id");
|
||||
entry.setId(keys.getLong(1));
|
||||
}
|
||||
} catch (SQLException insertFailure) {
|
||||
// Конкурирующий повтор из другой сессии мог успеть вставиться между SELECT и INSERT.
|
||||
KeyRotationCandidateBlockEntry raced = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber());
|
||||
if (raced != null
|
||||
&& Arrays.equals(raced.getBlockHash(), entry.getBlockHash())
|
||||
&& Arrays.equals(raced.getDataItemId(), entry.getDataItemId())) {
|
||||
return raced;
|
||||
}
|
||||
throw insertFailure;
|
||||
}
|
||||
entry.setArweavePublishPending(true);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public KeyRotationCandidateBlockEntry getByNumber(Connection c, long sessionId, int blockNumber) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT * FROM key_rotation_candidate_blocks
|
||||
WHERE rotation_session_id = ? AND block_number = ?
|
||||
""")) {
|
||||
ps.setLong(1, sessionId);
|
||||
ps.setInt(2, blockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? map(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int countStored(Connection c, long sessionId) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT COUNT(*) FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?
|
||||
""")) {
|
||||
ps.setLong(1, sessionId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getInt(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int countPublished(Connection c, long sessionId) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT COUNT(*) FROM key_rotation_candidate_blocks
|
||||
WHERE rotation_session_id = ? AND arweave_publish_pending = FALSE
|
||||
""")) {
|
||||
ps.setLong(1, sessionId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getInt(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Все candidate-блоки одной ротации в строгом порядке block_number. */
|
||||
public List<KeyRotationCandidateBlockEntry> listBySession(Connection c, long sessionId) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT * FROM key_rotation_candidate_blocks
|
||||
WHERE rotation_session_id = ?
|
||||
ORDER BY block_number
|
||||
""")) {
|
||||
ps.setLong(1, sessionId);
|
||||
List<KeyRotationCandidateBlockEntry> out = new ArrayList<>();
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(map(rs));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** Candidate-блоки имеют приоритет перед обычной очередью publisher-а. */
|
||||
public List<KeyRotationCandidateBlockEntry> listPendingArweave(int limit) throws SQLException {
|
||||
if (limit <= 0) return List.of();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT * FROM key_rotation_candidate_blocks
|
||||
WHERE arweave_publish_pending = TRUE
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
""")) {
|
||||
ps.setInt(1, limit);
|
||||
List<KeyRotationCandidateBlockEntry> out = new ArrayList<>();
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(map(rs));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Помечает DataItem опубликованными и синхронизирует progress_current ротации
|
||||
* с реальным количеством DataItem, подтверждённых publisher-ом.
|
||||
*/
|
||||
public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
|
||||
if (dataItemIds == null || dataItemIds.isEmpty()) return;
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
java.util.Set<Long> sessions = new java.util.HashSet<>();
|
||||
try (PreparedStatement find = c.prepareStatement("""
|
||||
SELECT rotation_session_id
|
||||
FROM key_rotation_candidate_blocks
|
||||
WHERE data_item_id = ?
|
||||
""")) {
|
||||
for (byte[] id : dataItemIds) {
|
||||
find.setBytes(1, id);
|
||||
try (ResultSet rs = find.executeQuery()) {
|
||||
if (rs.next()) sessions.add(rs.getLong(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE key_rotation_candidate_blocks
|
||||
SET arweave_publish_pending = FALSE,
|
||||
arweave_published_at_ms = COALESCE(arweave_published_at_ms, ?)
|
||||
WHERE data_item_id = ?
|
||||
""")) {
|
||||
for (byte[] id : dataItemIds) {
|
||||
ps.setLong(1, publishedAtMs);
|
||||
ps.setBytes(2, id);
|
||||
ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
}
|
||||
|
||||
try (PreparedStatement progress = c.prepareStatement("""
|
||||
UPDATE key_rotation_sessions s
|
||||
SET progress_current = LEAST(s.progress_total, (
|
||||
SELECT COUNT(*)::INTEGER
|
||||
FROM key_rotation_candidate_blocks b
|
||||
WHERE b.rotation_session_id = s.id
|
||||
AND b.arweave_publish_pending = FALSE
|
||||
)),
|
||||
updated_at_ms = ?
|
||||
WHERE s.id = ? AND s.status = 'COPYING_CHAIN'
|
||||
""")) {
|
||||
for (Long sessionId : sessions) {
|
||||
progress.setLong(1, publishedAtMs);
|
||||
progress.setLong(2, sessionId);
|
||||
progress.addBatch();
|
||||
}
|
||||
progress.executeBatch();
|
||||
}
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException sql) throw sql;
|
||||
throw new SQLException("Failed to mark candidate DataItems published", e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static KeyRotationCandidateBlockEntry map(ResultSet rs) throws SQLException {
|
||||
KeyRotationCandidateBlockEntry e = new KeyRotationCandidateBlockEntry();
|
||||
e.setId(rs.getLong("id"));
|
||||
e.setRotationSessionId(rs.getLong("rotation_session_id"));
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name"));
|
||||
e.setBlockNumber(rs.getInt("block_number"));
|
||||
e.setBlockHash(rs.getBytes("block_hash"));
|
||||
e.setDataItemId(rs.getBytes("data_item_id"));
|
||||
e.setBlockBytes(rs.getBytes("block_bytes"));
|
||||
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending"));
|
||||
e.setArweavePublishedAtMs((Long) rs.getObject("arweave_published_at_ms"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.KeyRotationSessionEntry;
|
||||
import shine.db.entities.KeyRotationStatus;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
/**
|
||||
* DAO для серверной машины смены ключей.
|
||||
*
|
||||
* Все методы изменения статуса синхронно обновляют и key_rotation_sessions,
|
||||
* и solana_user_pda_current.rotation_status / rotation_session_id.
|
||||
*/
|
||||
public final class KeyRotationSessionsDAO {
|
||||
|
||||
private static volatile KeyRotationSessionsDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private KeyRotationSessionsDAO() { }
|
||||
|
||||
public static KeyRotationSessionsDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (KeyRotationSessionsDAO.class) {
|
||||
if (instance == null) instance = new KeyRotationSessionsDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Атомарно создаёт первую серверную запись ротации сразу в COPYING_CHAIN.
|
||||
* PREPARING в БД отсутствует: до этого момента всё является локальным UI-состоянием.
|
||||
*/
|
||||
public KeyRotationSessionEntry createCopyingSession(KeyRotationSessionEntry entry) throws SQLException {
|
||||
validateNewSession(entry);
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
KeyRotationSessionEntry created = createCopyingSession(c, entry);
|
||||
c.commit();
|
||||
return created;
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException sql) throw sql;
|
||||
throw new SQLException("Failed to create key rotation session", e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public KeyRotationSessionEntry createCopyingSession(Connection c, KeyRotationSessionEntry entry) throws SQLException {
|
||||
validateNewSession(entry);
|
||||
|
||||
String currentStatus;
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT rotation_status
|
||||
FROM solana_user_pda_current
|
||||
WHERE login = ?
|
||||
FOR UPDATE
|
||||
""")) {
|
||||
ps.setString(1, entry.getLogin());
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) throw new SQLException("Unknown login for key rotation: " + entry.getLogin());
|
||||
currentStatus = rs.getString(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!KeyRotationStatus.NONE.name().equals(currentStatus)) {
|
||||
throw new SQLException("Key rotation already active for login=" + entry.getLogin() + ", status=" + currentStatus);
|
||||
}
|
||||
|
||||
long now = entry.getCreatedAtMs() > 0 ? entry.getCreatedAtMs() : System.currentTimeMillis();
|
||||
entry.setCreatedAtMs(now);
|
||||
entry.setUpdatedAtMs(now);
|
||||
entry.setStatus(KeyRotationStatus.COPYING_CHAIN);
|
||||
if (entry.getComment() == null) entry.setComment("");
|
||||
if (entry.getWalletMigrationStatus() == null) entry.setWalletMigrationStatus("PENDING");
|
||||
if (entry.getMessageMigrationStatus() == null) entry.setMessageMigrationStatus("PENDING");
|
||||
|
||||
String sql = """
|
||||
INSERT INTO key_rotation_sessions (
|
||||
login, status,
|
||||
source_blockchain_name, candidate_blockchain_name,
|
||||
old_root_key, old_blockchain_key, old_client_key,
|
||||
new_root_key, new_blockchain_key, new_client_key,
|
||||
fork_from_block, fork_from_hash,
|
||||
source_tip_block, source_tip_hash,
|
||||
reason_code, comment,
|
||||
progress_current, progress_total,
|
||||
pda_rotation_signature,
|
||||
wallet_migration_status, message_migration_status,
|
||||
last_error, last_error_at_ms, retry_count,
|
||||
created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
""";
|
||||
|
||||
long id;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||
int i = 1;
|
||||
ps.setString(i++, entry.getLogin());
|
||||
ps.setString(i++, entry.getStatus().name());
|
||||
ps.setString(i++, entry.getSourceBlockchainName());
|
||||
ps.setString(i++, entry.getCandidateBlockchainName());
|
||||
ps.setString(i++, entry.getOldRootKey());
|
||||
ps.setString(i++, entry.getOldBlockchainKey());
|
||||
ps.setString(i++, entry.getOldClientKey());
|
||||
ps.setString(i++, entry.getNewRootKey());
|
||||
ps.setString(i++, entry.getNewBlockchainKey());
|
||||
ps.setString(i++, entry.getNewClientKey());
|
||||
ps.setInt(i++, entry.getForkFromBlock());
|
||||
ps.setBytes(i++, entry.getForkFromHash());
|
||||
ps.setInt(i++, entry.getSourceTipBlock());
|
||||
ps.setBytes(i++, entry.getSourceTipHash());
|
||||
ps.setShort(i++, entry.getReasonCode());
|
||||
ps.setString(i++, entry.getComment());
|
||||
ps.setInt(i++, entry.getProgressCurrent());
|
||||
ps.setInt(i++, entry.getProgressTotal());
|
||||
ps.setString(i++, entry.getPdaRotationSignature());
|
||||
ps.setString(i++, entry.getWalletMigrationStatus());
|
||||
ps.setString(i++, entry.getMessageMigrationStatus());
|
||||
ps.setString(i++, entry.getLastError());
|
||||
if (entry.getLastErrorAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getLastErrorAtMs());
|
||||
ps.setInt(i++, entry.getRetryCount());
|
||||
ps.setLong(i++, entry.getCreatedAtMs());
|
||||
ps.setLong(i++, entry.getUpdatedAtMs());
|
||||
if (entry.getCompletedAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getCompletedAtMs());
|
||||
if (entry.getAbortedAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getAbortedAtMs());
|
||||
ps.executeUpdate();
|
||||
try (ResultSet keys = ps.getGeneratedKeys()) {
|
||||
if (!keys.next()) throw new SQLException("key_rotation_sessions insert returned no id");
|
||||
id = keys.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE solana_user_pda_current
|
||||
SET rotation_status = ?, rotation_session_id = ?
|
||||
WHERE login = ? AND rotation_status = 'NONE'
|
||||
""")) {
|
||||
ps.setString(1, KeyRotationStatus.COPYING_CHAIN.name());
|
||||
ps.setLong(2, id);
|
||||
ps.setString(3, entry.getLogin());
|
||||
if (ps.executeUpdate() != 1) {
|
||||
throw new SQLException("Failed to attach key rotation session to login=" + entry.getLogin());
|
||||
}
|
||||
}
|
||||
|
||||
entry.setId(id);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public KeyRotationSessionEntry getById(long id) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getById(c, id);
|
||||
}
|
||||
}
|
||||
|
||||
public KeyRotationSessionEntry getById(Connection c, long id) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ?")) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public KeyRotationSessionEntry getActiveByLogin(String login) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getActiveByLogin(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
public KeyRotationSessionEntry getActiveByLogin(Connection c, String login) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement(selectBase() + """
|
||||
WHERE login = ?
|
||||
AND status NOT IN ('COMPLETE', 'ABORTED')
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Возвращает несколько сессий одного состояния для фоновых workers. */
|
||||
public java.util.List<KeyRotationSessionEntry> listByStatus(KeyRotationStatus status, int limit) throws SQLException {
|
||||
if (status == null || status == KeyRotationStatus.NONE) return java.util.List.of();
|
||||
int safeLimit = Math.max(1, Math.min(limit, 100));
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(selectBase() + """
|
||||
WHERE status = ?
|
||||
ORDER BY updated_at_ms, id
|
||||
LIMIT ?
|
||||
""")) {
|
||||
ps.setString(1, status.name());
|
||||
ps.setInt(2, safeLimit);
|
||||
java.util.List<KeyRotationSessionEntry> out = new java.util.ArrayList<>();
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Атомарный переход state machine. Смена статуса выполняется только из expected.
|
||||
*/
|
||||
public KeyRotationSessionEntry transition(long id,
|
||||
KeyRotationStatus expected,
|
||||
KeyRotationStatus next) throws SQLException {
|
||||
if (expected == null || next == null || !expected.canTransitionTo(next)) {
|
||||
throw new IllegalArgumentException("Forbidden key rotation transition: " + expected + " -> " + next);
|
||||
}
|
||||
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
|
||||
if (current == null) throw new SQLException("Unknown key rotation session id=" + id);
|
||||
if (current.getStatus() != expected) {
|
||||
throw new SQLException("Key rotation status mismatch: expected=" + expected + ", actual=" + current.getStatus());
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
Long completed = current.getCompletedAtMs();
|
||||
if (next == KeyRotationStatus.COMPLETE) completed = now;
|
||||
Long aborted = current.getAbortedAtMs();
|
||||
if (next == KeyRotationStatus.ABORTED) aborted = now;
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE key_rotation_sessions
|
||||
SET status = ?, updated_at_ms = ?, completed_at_ms = ?, aborted_at_ms = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""")) {
|
||||
ps.setString(1, next.name());
|
||||
ps.setLong(2, now);
|
||||
if (completed == null) ps.setNull(3, java.sql.Types.BIGINT); else ps.setLong(3, completed);
|
||||
if (aborted == null) ps.setNull(4, java.sql.Types.BIGINT); else ps.setLong(4, aborted);
|
||||
ps.setLong(5, id);
|
||||
ps.setString(6, expected.name());
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent key rotation transition for id=" + id);
|
||||
}
|
||||
|
||||
KeyRotationStatus userStatus = next.userStatus();
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE solana_user_pda_current
|
||||
SET rotation_status = ?, rotation_session_id = ?
|
||||
WHERE login = ? AND rotation_session_id = ?
|
||||
""")) {
|
||||
ps.setString(1, userStatus.name());
|
||||
if (userStatus == KeyRotationStatus.NONE) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, id);
|
||||
ps.setString(3, current.getLogin());
|
||||
ps.setLong(4, id);
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin());
|
||||
}
|
||||
|
||||
// Candidate-копии нужны только пока ротация активна. В Arweave они уже опубликованы,
|
||||
// а в PostgreSQL после COMPLETE/ABORTED временные строки больше не нужны.
|
||||
if (next.isTerminalSessionStatus()) {
|
||||
try (PreparedStatement ps = c.prepareStatement(
|
||||
"DELETE FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?")) {
|
||||
ps.setLong(1, id);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
c.commit();
|
||||
current.setStatus(next);
|
||||
current.setUpdatedAtMs(now);
|
||||
current.setCompletedAtMs(completed);
|
||||
current.setAbortedAtMs(aborted);
|
||||
return current;
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException sql) throw sql;
|
||||
throw new SQLException("Failed key rotation transition", e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void updateProgress(long id, int current, int total) throws SQLException {
|
||||
if (current < 0 || total < 0 || current > total) {
|
||||
throw new IllegalArgumentException("Invalid rotation progress: " + current + "/" + total);
|
||||
}
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE key_rotation_sessions
|
||||
SET progress_current = ?, progress_total = ?, updated_at_ms = ?
|
||||
WHERE id = ? AND status = 'COPYING_CHAIN'
|
||||
""")) {
|
||||
ps.setInt(1, current);
|
||||
ps.setInt(2, total);
|
||||
ps.setLong(3, System.currentTimeMillis());
|
||||
ps.setLong(4, id);
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("Rotation progress can only be updated in COPYING_CHAIN, id=" + id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Атомарно фиксирует tx signature и переводит CHAIN_READY -> ROTATING_PDA.
|
||||
* Это точка, после которой Abort запрещён: отправленная Solana-транзакция может подтвердиться позже.
|
||||
*/
|
||||
public KeyRotationSessionEntry beginPdaRotation(long id, String signature) throws SQLException {
|
||||
if (signature == null || signature.isBlank()) throw new IllegalArgumentException("signature is empty");
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
|
||||
if (current == null) throw new SQLException("Unknown key rotation session id=" + id);
|
||||
if (current.getStatus() != KeyRotationStatus.CHAIN_READY) {
|
||||
throw new SQLException("PDA rotation requires CHAIN_READY, actual=" + current.getStatus());
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE key_rotation_sessions
|
||||
SET status = 'ROTATING_PDA', pda_rotation_signature = ?, updated_at_ms = ?,
|
||||
last_error = NULL, last_error_at_ms = NULL
|
||||
WHERE id = ? AND status = 'CHAIN_READY'
|
||||
""")) {
|
||||
ps.setString(1, signature);
|
||||
ps.setLong(2, now);
|
||||
ps.setLong(3, id);
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation start for id=" + id);
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE solana_user_pda_current
|
||||
SET rotation_status = 'ROTATING_PDA', rotation_session_id = ?
|
||||
WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'CHAIN_READY'
|
||||
""")) {
|
||||
ps.setLong(1, id);
|
||||
ps.setString(2, current.getLogin());
|
||||
ps.setLong(3, id);
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin());
|
||||
}
|
||||
c.commit();
|
||||
current.setStatus(KeyRotationStatus.ROTATING_PDA);
|
||||
current.setPdaRotationSignature(signature);
|
||||
current.setUpdatedAtMs(now);
|
||||
current.setLastError(null);
|
||||
current.setLastErrorAtMs(null);
|
||||
return current;
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException sql) throw sql;
|
||||
throw new SQLException("Failed to begin PDA rotation", e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Если Solana sync уже записал в current-PDA весь ожидаемый новый набор ключей,
|
||||
* атомарно переводит ROTATING_PDA -> PDA_ROTATED. Иначе возвращает null.
|
||||
*/
|
||||
public KeyRotationSessionEntry tryMarkPdaRotatedFromCurrentState(long id) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
|
||||
if (current == null || current.getStatus() != KeyRotationStatus.ROTATING_PDA) {
|
||||
c.rollback();
|
||||
return current != null && current.getStatus() == KeyRotationStatus.PDA_ROTATED ? current : null;
|
||||
}
|
||||
|
||||
String root;
|
||||
String blockchain;
|
||||
String client;
|
||||
String blockchainName;
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT root_key, blockchain_key, client_key, blockchain_name
|
||||
FROM solana_user_pda_current
|
||||
WHERE login = ?
|
||||
FOR UPDATE
|
||||
""")) {
|
||||
ps.setString(1, current.getLogin());
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) { c.rollback(); return null; }
|
||||
root = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("root_key"));
|
||||
blockchain = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key"));
|
||||
client = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("client_key"));
|
||||
blockchainName = rs.getString("blockchain_name");
|
||||
}
|
||||
}
|
||||
|
||||
if (!current.getNewRootKey().equals(root)
|
||||
|| !current.getNewBlockchainKey().equals(blockchain)
|
||||
|| !current.getNewClientKey().equals(client)
|
||||
|| !current.getCandidateBlockchainName().equals(blockchainName)) {
|
||||
c.rollback();
|
||||
return null;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE key_rotation_sessions
|
||||
SET status = 'PDA_ROTATED', updated_at_ms = ?, last_error = NULL, last_error_at_ms = NULL
|
||||
WHERE id = ? AND status = 'ROTATING_PDA'
|
||||
""")) {
|
||||
ps.setLong(1, now);
|
||||
ps.setLong(2, id);
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation confirm for id=" + id);
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE solana_user_pda_current
|
||||
SET rotation_status = 'PDA_ROTATED', rotation_session_id = ?
|
||||
WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'ROTATING_PDA'
|
||||
""")) {
|
||||
ps.setLong(1, id);
|
||||
ps.setString(2, current.getLogin());
|
||||
ps.setLong(3, id);
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to current PDA login=" + current.getLogin());
|
||||
}
|
||||
c.commit();
|
||||
current.setStatus(KeyRotationStatus.PDA_ROTATED);
|
||||
current.setUpdatedAtMs(now);
|
||||
current.setLastError(null);
|
||||
current.setLastErrorAtMs(null);
|
||||
return current;
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException sql) throw sql;
|
||||
throw new SQLException("Failed to confirm rotated PDA", e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setPdaRotationSignature(long id, String signature) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE key_rotation_sessions
|
||||
SET pda_rotation_signature = ?, updated_at_ms = ?
|
||||
WHERE id = ? AND status = 'ROTATING_PDA'
|
||||
""")) {
|
||||
ps.setString(1, signature);
|
||||
ps.setLong(2, System.currentTimeMillis());
|
||||
ps.setLong(3, id);
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("PDA signature requires ROTATING_PDA, id=" + id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Фиксирует результат необязательного этапа wallet/DM.
|
||||
* Имя колонки выбирается только из закрытого списка — внешние значения в SQL не подставляются.
|
||||
*/
|
||||
public void setMigrationSubStatus(long id, String column, String value) throws SQLException {
|
||||
String safeColumn = switch (String.valueOf(column)) {
|
||||
case "wallet_migration_status" -> "wallet_migration_status";
|
||||
case "message_migration_status" -> "message_migration_status";
|
||||
default -> throw new IllegalArgumentException("Unsupported migration status column: " + column);
|
||||
};
|
||||
String safeValue = switch (String.valueOf(value)) {
|
||||
case "PENDING", "COMPLETE", "SKIPPED", "NOT_IMPLEMENTED" -> String.valueOf(value);
|
||||
default -> throw new IllegalArgumentException("Unsupported migration status value: " + value);
|
||||
};
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(
|
||||
"UPDATE key_rotation_sessions SET " + safeColumn + " = ?, updated_at_ms = ? WHERE id = ?")) {
|
||||
ps.setString(1, safeValue);
|
||||
ps.setLong(2, System.currentTimeMillis());
|
||||
ps.setLong(3, id);
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("Unknown key rotation session id=" + id);
|
||||
}
|
||||
}
|
||||
|
||||
public void recordError(long id, String error) throws SQLException {
|
||||
long now = System.currentTimeMillis();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE key_rotation_sessions
|
||||
SET last_error = ?, last_error_at_ms = ?, retry_count = retry_count + 1, updated_at_ms = ?
|
||||
WHERE id = ? AND status NOT IN ('COMPLETE', 'ABORTED')
|
||||
""")) {
|
||||
ps.setString(1, error);
|
||||
ps.setLong(2, now);
|
||||
ps.setLong(3, now);
|
||||
ps.setLong(4, id);
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("Cannot record error for inactive rotation id=" + id);
|
||||
}
|
||||
}
|
||||
|
||||
public void clearError(long id) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE key_rotation_sessions
|
||||
SET last_error = NULL, last_error_at_ms = NULL, updated_at_ms = ?
|
||||
WHERE id = ?
|
||||
""")) {
|
||||
ps.setLong(1, System.currentTimeMillis());
|
||||
ps.setLong(2, id);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private KeyRotationSessionEntry getByIdForUpdate(Connection c, long id) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ? FOR UPDATE")) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapRow(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String selectBase() {
|
||||
return """
|
||||
SELECT
|
||||
id, login, status,
|
||||
source_blockchain_name, candidate_blockchain_name,
|
||||
old_root_key, old_blockchain_key, old_client_key,
|
||||
new_root_key, new_blockchain_key, new_client_key,
|
||||
fork_from_block, fork_from_hash,
|
||||
source_tip_block, source_tip_hash,
|
||||
reason_code, comment,
|
||||
progress_current, progress_total,
|
||||
pda_rotation_signature,
|
||||
wallet_migration_status, message_migration_status,
|
||||
last_error, last_error_at_ms, retry_count,
|
||||
created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms
|
||||
FROM key_rotation_sessions
|
||||
""";
|
||||
}
|
||||
|
||||
private static KeyRotationSessionEntry mapRow(ResultSet rs) throws SQLException {
|
||||
KeyRotationSessionEntry e = new KeyRotationSessionEntry();
|
||||
e.setId(rs.getLong("id"));
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setStatus(KeyRotationStatus.valueOf(rs.getString("status")));
|
||||
e.setSourceBlockchainName(rs.getString("source_blockchain_name"));
|
||||
e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name"));
|
||||
e.setOldRootKey(rs.getString("old_root_key"));
|
||||
e.setOldBlockchainKey(rs.getString("old_blockchain_key"));
|
||||
e.setOldClientKey(rs.getString("old_client_key"));
|
||||
e.setNewRootKey(rs.getString("new_root_key"));
|
||||
e.setNewBlockchainKey(rs.getString("new_blockchain_key"));
|
||||
e.setNewClientKey(rs.getString("new_client_key"));
|
||||
e.setForkFromBlock(rs.getInt("fork_from_block"));
|
||||
e.setForkFromHash(rs.getBytes("fork_from_hash"));
|
||||
e.setSourceTipBlock(rs.getInt("source_tip_block"));
|
||||
e.setSourceTipHash(rs.getBytes("source_tip_hash"));
|
||||
e.setReasonCode(rs.getShort("reason_code"));
|
||||
e.setComment(rs.getString("comment"));
|
||||
e.setProgressCurrent(rs.getInt("progress_current"));
|
||||
e.setProgressTotal(rs.getInt("progress_total"));
|
||||
e.setPdaRotationSignature(rs.getString("pda_rotation_signature"));
|
||||
e.setWalletMigrationStatus(rs.getString("wallet_migration_status"));
|
||||
e.setMessageMigrationStatus(rs.getString("message_migration_status"));
|
||||
e.setLastError(rs.getString("last_error"));
|
||||
long lastErrorAt = rs.getLong("last_error_at_ms");
|
||||
e.setLastErrorAtMs(rs.wasNull() ? null : lastErrorAt);
|
||||
e.setRetryCount(rs.getInt("retry_count"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
long completed = rs.getLong("completed_at_ms");
|
||||
e.setCompletedAtMs(rs.wasNull() ? null : completed);
|
||||
long aborted = rs.getLong("aborted_at_ms");
|
||||
e.setAbortedAtMs(rs.wasNull() ? null : aborted);
|
||||
return e;
|
||||
}
|
||||
|
||||
private static void validateNewSession(KeyRotationSessionEntry e) {
|
||||
if (e == null) throw new IllegalArgumentException("entry is null");
|
||||
if (blank(e.getLogin())) throw new IllegalArgumentException("login is required");
|
||||
if (blank(e.getSourceBlockchainName()) || blank(e.getCandidateBlockchainName())) {
|
||||
throw new IllegalArgumentException("source/candidate blockchain names are required");
|
||||
}
|
||||
if (blank(e.getOldRootKey()) || blank(e.getOldBlockchainKey()) || blank(e.getOldClientKey())
|
||||
|| blank(e.getNewRootKey()) || blank(e.getNewBlockchainKey()) || blank(e.getNewClientKey())) {
|
||||
throw new IllegalArgumentException("all old/new public keys are required");
|
||||
}
|
||||
if (e.getForkFromHash() == null || e.getForkFromHash().length != 32) {
|
||||
throw new IllegalArgumentException("forkFromHash must be 32 bytes");
|
||||
}
|
||||
if (e.getSourceTipHash() == null || e.getSourceTipHash().length != 32) {
|
||||
throw new IllegalArgumentException("sourceTipHash must be 32 bytes");
|
||||
}
|
||||
if (e.getForkFromBlock() < 0 || e.getSourceTipBlock() < e.getForkFromBlock()) {
|
||||
throw new IllegalArgumentException("invalid fork/source tip block numbers");
|
||||
}
|
||||
if (e.getReasonCode() < 1 || e.getReasonCode() > 4) {
|
||||
throw new IllegalArgumentException("reasonCode must be 1..4");
|
||||
}
|
||||
String comment = e.getComment() == null ? "" : e.getComment();
|
||||
if (comment.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1024) {
|
||||
throw new IllegalArgumentException("comment must be <= 1024 UTF-8 bytes");
|
||||
}
|
||||
if (e.getProgressCurrent() < 0 || e.getProgressTotal() < e.getProgressCurrent()) {
|
||||
throw new IllegalArgumentException("invalid initial progress");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean blank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,27 @@ public final class SolanaUserPdaCurrentDAO {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public SolanaUserPdaCurrentEntry getByLogin(String login) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
blockchain_name,
|
||||
blockchain_key,
|
||||
paid_limit_bytes
|
||||
FROM solana_user_pda_current
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
LIMIT 1
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
|
||||
+45
@@ -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; }
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package shine.db.entities;
|
||||
|
||||
/**
|
||||
* Публичное/runtime-состояние одной смены ключей.
|
||||
* Приватные ключи и пароли в этой сущности не хранятся никогда.
|
||||
*/
|
||||
public final class KeyRotationSessionEntry {
|
||||
private long id;
|
||||
private String login;
|
||||
private KeyRotationStatus status;
|
||||
private String sourceBlockchainName;
|
||||
private String candidateBlockchainName;
|
||||
private String oldRootKey;
|
||||
private String oldBlockchainKey;
|
||||
private String oldClientKey;
|
||||
private String newRootKey;
|
||||
private String newBlockchainKey;
|
||||
private String newClientKey;
|
||||
private int forkFromBlock;
|
||||
private byte[] forkFromHash;
|
||||
private int sourceTipBlock;
|
||||
private byte[] sourceTipHash;
|
||||
private short reasonCode;
|
||||
private String comment;
|
||||
private int progressCurrent;
|
||||
private int progressTotal;
|
||||
private String pdaRotationSignature;
|
||||
private String walletMigrationStatus;
|
||||
private String messageMigrationStatus;
|
||||
private String lastError;
|
||||
private Long lastErrorAtMs;
|
||||
private int retryCount;
|
||||
private long createdAtMs;
|
||||
private long updatedAtMs;
|
||||
private Long completedAtMs;
|
||||
private Long abortedAtMs;
|
||||
|
||||
public long getId() { return id; }
|
||||
public void setId(long id) { this.id = id; }
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public KeyRotationStatus getStatus() { return status; }
|
||||
public void setStatus(KeyRotationStatus status) { this.status = status; }
|
||||
public String getSourceBlockchainName() { return sourceBlockchainName; }
|
||||
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
|
||||
public String getCandidateBlockchainName() { return candidateBlockchainName; }
|
||||
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
|
||||
public String getOldRootKey() { return oldRootKey; }
|
||||
public void setOldRootKey(String oldRootKey) { this.oldRootKey = oldRootKey; }
|
||||
public String getOldBlockchainKey() { return oldBlockchainKey; }
|
||||
public void setOldBlockchainKey(String oldBlockchainKey) { this.oldBlockchainKey = oldBlockchainKey; }
|
||||
public String getOldClientKey() { return oldClientKey; }
|
||||
public void setOldClientKey(String oldClientKey) { this.oldClientKey = oldClientKey; }
|
||||
public String getNewRootKey() { return newRootKey; }
|
||||
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
|
||||
public String getNewBlockchainKey() { return newBlockchainKey; }
|
||||
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
|
||||
public String getNewClientKey() { return newClientKey; }
|
||||
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
|
||||
public int getForkFromBlock() { return forkFromBlock; }
|
||||
public void setForkFromBlock(int forkFromBlock) { this.forkFromBlock = forkFromBlock; }
|
||||
public byte[] getForkFromHash() { return forkFromHash; }
|
||||
public void setForkFromHash(byte[] forkFromHash) { this.forkFromHash = forkFromHash; }
|
||||
public int getSourceTipBlock() { return sourceTipBlock; }
|
||||
public void setSourceTipBlock(int sourceTipBlock) { this.sourceTipBlock = sourceTipBlock; }
|
||||
public byte[] getSourceTipHash() { return sourceTipHash; }
|
||||
public void setSourceTipHash(byte[] sourceTipHash) { this.sourceTipHash = sourceTipHash; }
|
||||
public short getReasonCode() { return reasonCode; }
|
||||
public void setReasonCode(short reasonCode) { this.reasonCode = reasonCode; }
|
||||
public String getComment() { return comment; }
|
||||
public void setComment(String comment) { this.comment = comment; }
|
||||
public int getProgressCurrent() { return progressCurrent; }
|
||||
public void setProgressCurrent(int progressCurrent) { this.progressCurrent = progressCurrent; }
|
||||
public int getProgressTotal() { return progressTotal; }
|
||||
public void setProgressTotal(int progressTotal) { this.progressTotal = progressTotal; }
|
||||
public String getPdaRotationSignature() { return pdaRotationSignature; }
|
||||
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
|
||||
public String getWalletMigrationStatus() { return walletMigrationStatus; }
|
||||
public void setWalletMigrationStatus(String walletMigrationStatus) { this.walletMigrationStatus = walletMigrationStatus; }
|
||||
public String getMessageMigrationStatus() { return messageMigrationStatus; }
|
||||
public void setMessageMigrationStatus(String messageMigrationStatus) { this.messageMigrationStatus = messageMigrationStatus; }
|
||||
public String getLastError() { return lastError; }
|
||||
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||
public Long getLastErrorAtMs() { return lastErrorAtMs; }
|
||||
public void setLastErrorAtMs(Long lastErrorAtMs) { this.lastErrorAtMs = lastErrorAtMs; }
|
||||
public int getRetryCount() { return retryCount; }
|
||||
public void setRetryCount(int retryCount) { this.retryCount = retryCount; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||
public Long getCompletedAtMs() { return completedAtMs; }
|
||||
public void setCompletedAtMs(Long completedAtMs) { this.completedAtMs = completedAtMs; }
|
||||
public Long getAbortedAtMs() { return abortedAtMs; }
|
||||
public void setAbortedAtMs(Long abortedAtMs) { this.abortedAtMs = abortedAtMs; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package shine.db.entities;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Состояния серверной машины смены ключей.
|
||||
*
|
||||
* NONE используется только в solana_user_pda_current.rotation_status.
|
||||
* COMPLETE/ABORTED остаются в истории key_rotation_sessions, после чего
|
||||
* пользователь снова получает rotation_status=NONE.
|
||||
*/
|
||||
public enum KeyRotationStatus {
|
||||
NONE,
|
||||
COPYING_CHAIN,
|
||||
CHAIN_READY,
|
||||
ROTATING_PDA,
|
||||
PDA_ROTATED,
|
||||
REBUILDING_SERVER,
|
||||
WALLET_MIGRATION,
|
||||
MESSAGE_MIGRATION,
|
||||
FINALIZING,
|
||||
COMPLETE,
|
||||
ABORTED;
|
||||
|
||||
public boolean isTerminalSessionStatus() {
|
||||
return this == COMPLETE || this == ABORTED;
|
||||
}
|
||||
|
||||
public boolean isUserActiveStatus() {
|
||||
return this != NONE && !isTerminalSessionStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Допустимые переходы одной ротации.
|
||||
* Abort сознательно разрешён только до начала ROTATING_PDA: после отправки
|
||||
* Solana-транзакции нельзя надёжно знать, не будет ли она подтверждена позже.
|
||||
*/
|
||||
public boolean canTransitionTo(KeyRotationStatus next) {
|
||||
if (next == null) return false;
|
||||
return switch (this) {
|
||||
case COPYING_CHAIN -> EnumSet.of(CHAIN_READY, ABORTED).contains(next);
|
||||
case CHAIN_READY -> EnumSet.of(ROTATING_PDA, ABORTED).contains(next);
|
||||
case ROTATING_PDA -> next == PDA_ROTATED;
|
||||
case PDA_ROTATED -> next == REBUILDING_SERVER;
|
||||
case REBUILDING_SERVER -> next == WALLET_MIGRATION;
|
||||
case WALLET_MIGRATION -> next == MESSAGE_MIGRATION;
|
||||
case MESSAGE_MIGRATION -> next == FINALIZING;
|
||||
case FINALIZING -> next == COMPLETE;
|
||||
case NONE, COMPLETE, ABORTED -> false;
|
||||
};
|
||||
}
|
||||
|
||||
public static Set<KeyRotationStatus> activeStatuses() {
|
||||
return EnumSet.of(
|
||||
COPYING_CHAIN,
|
||||
CHAIN_READY,
|
||||
ROTATING_PDA,
|
||||
PDA_ROTATED,
|
||||
REBUILDING_SERVER,
|
||||
WALLET_MIGRATION,
|
||||
MESSAGE_MIGRATION,
|
||||
FINALIZING
|
||||
);
|
||||
}
|
||||
|
||||
/** Статус, который должен храниться у пользователя для данной session-state. */
|
||||
public KeyRotationStatus userStatus() {
|
||||
return isTerminalSessionStatus() ? NONE : this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
-- v26: серверная state machine смены ключей пользователя.
|
||||
--
|
||||
-- Важно:
|
||||
-- - rotation_status / rotation_session_id являются ЛОКАЛЬНЫМ runtime-состоянием сервера,
|
||||
-- а не полями Solana PDA;
|
||||
-- - Solana sync не должен перезаписывать эти колонки при upsert PDA;
|
||||
-- - приватные ключи здесь никогда не хранятся, только публичные ключи ротации.
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
|
||||
rotation_status IN (
|
||||
'NONE',
|
||||
'COPYING_CHAIN',
|
||||
'CHAIN_READY',
|
||||
'ROTATING_PDA',
|
||||
'PDA_ROTATED',
|
||||
'REBUILDING_SERVER',
|
||||
'WALLET_MIGRATION',
|
||||
'MESSAGE_MIGRATION',
|
||||
'FINALIZING'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
|
||||
ON solana_user_pda_current(rotation_status)
|
||||
WHERE rotation_status <> 'NONE';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK (
|
||||
status IN (
|
||||
'COPYING_CHAIN',
|
||||
'CHAIN_READY',
|
||||
'ROTATING_PDA',
|
||||
'PDA_ROTATED',
|
||||
'REBUILDING_SERVER',
|
||||
'WALLET_MIGRATION',
|
||||
'MESSAGE_MIGRATION',
|
||||
'FINALIZING',
|
||||
'COMPLETE',
|
||||
'ABORTED'
|
||||
)
|
||||
),
|
||||
|
||||
source_blockchain_name TEXT NOT NULL,
|
||||
candidate_blockchain_name TEXT NOT NULL,
|
||||
|
||||
old_root_key TEXT NOT NULL,
|
||||
old_blockchain_key TEXT NOT NULL,
|
||||
old_client_key TEXT NOT NULL,
|
||||
new_root_key TEXT NOT NULL,
|
||||
new_blockchain_key TEXT NOT NULL,
|
||||
new_client_key TEXT NOT NULL,
|
||||
|
||||
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
|
||||
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
|
||||
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
|
||||
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
|
||||
|
||||
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
|
||||
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
|
||||
|
||||
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
|
||||
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
|
||||
|
||||
pda_rotation_signature TEXT,
|
||||
|
||||
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||
wallet_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
|
||||
),
|
||||
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||
message_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
|
||||
),
|
||||
|
||||
last_error TEXT,
|
||||
last_error_at_ms BIGINT,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
completed_at_ms BIGINT,
|
||||
aborted_at_ms BIGINT,
|
||||
|
||||
CHECK (progress_current <= progress_total),
|
||||
CHECK (fork_from_block <= source_tip_block)
|
||||
);
|
||||
|
||||
-- У одного login одновременно может существовать только одна незавершённая ротация.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
|
||||
ON key_rotation_sessions(login)
|
||||
WHERE status NOT IN ('COMPLETE', 'ABORTED');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
|
||||
ON key_rotation_sessions(login, created_at_ms DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
|
||||
ON key_rotation_sessions(status);
|
||||
|
||||
-- Быстрый указатель из текущего пользователя на активную/последнюю runtime-сессию.
|
||||
-- FK добавляется после создания key_rotation_sessions, чтобы не создавать циклический DDL порядок.
|
||||
ALTER TABLE solana_user_pda_current
|
||||
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
|
||||
FOREIGN KEY (rotation_session_id)
|
||||
REFERENCES key_rotation_sessions(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
UPDATE db_schema_version
|
||||
SET schema_version = 26,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHERE id = 1;
|
||||
@@ -0,0 +1,31 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
|
||||
login TEXT NOT NULL,
|
||||
candidate_blockchain_name TEXT NOT NULL,
|
||||
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
|
||||
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
|
||||
block_bytes BYTEA NOT NULL,
|
||||
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
arweave_published_at_ms BIGINT,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
UNIQUE (rotation_session_id, block_number),
|
||||
UNIQUE (rotation_session_id, data_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
|
||||
ON key_rotation_candidate_blocks(id)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
|
||||
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
|
||||
|
||||
UPDATE db_schema_version
|
||||
SET schema_version = 27,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHERE id = 1;
|
||||
|
||||
COMMIT;
|
||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
||||
);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 24, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
VALUES (1, 26, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
@@ -2081,8 +2081,111 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
|
||||
ON solana_user_pda_current(is_server, archive_imported)
|
||||
WHERE archive_head_tx_id <> '';
|
||||
|
||||
-- Server-local key rotation state machine (schema v26).
|
||||
-- Эти поля не являются частью Solana PDA и не перезаписываются Solana sync upsert-ом.
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
|
||||
rotation_status IN (
|
||||
'NONE','COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
|
||||
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
|
||||
ON solana_user_pda_current(rotation_status)
|
||||
WHERE rotation_status <> 'NONE';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK (
|
||||
status IN (
|
||||
'COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
|
||||
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING',
|
||||
'COMPLETE','ABORTED'
|
||||
)
|
||||
),
|
||||
source_blockchain_name TEXT NOT NULL,
|
||||
candidate_blockchain_name TEXT NOT NULL,
|
||||
old_root_key TEXT NOT NULL,
|
||||
old_blockchain_key TEXT NOT NULL,
|
||||
old_client_key TEXT NOT NULL,
|
||||
new_root_key TEXT NOT NULL,
|
||||
new_blockchain_key TEXT NOT NULL,
|
||||
new_client_key TEXT NOT NULL,
|
||||
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
|
||||
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
|
||||
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
|
||||
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
|
||||
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
|
||||
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
|
||||
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
|
||||
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
|
||||
pda_rotation_signature TEXT,
|
||||
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||
wallet_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
|
||||
),
|
||||
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||
message_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
|
||||
),
|
||||
last_error TEXT,
|
||||
last_error_at_ms BIGINT,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
completed_at_ms BIGINT,
|
||||
aborted_at_ms BIGINT,
|
||||
CHECK (progress_current <= progress_total),
|
||||
CHECK (fork_from_block <= source_tip_block)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
|
||||
ON key_rotation_sessions(login)
|
||||
WHERE status NOT IN ('COMPLETE','ABORTED');
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
|
||||
ON key_rotation_sessions(login, created_at_ms DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
|
||||
ON key_rotation_sessions(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
|
||||
login TEXT NOT NULL,
|
||||
candidate_blockchain_name TEXT NOT NULL,
|
||||
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
|
||||
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
|
||||
block_bytes BYTEA NOT NULL,
|
||||
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
arweave_published_at_ms BIGINT,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
UNIQUE (rotation_session_id, block_number),
|
||||
UNIQUE (rotation_session_id, data_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
|
||||
ON key_rotation_candidate_blocks(id)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
|
||||
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
|
||||
FOREIGN KEY (rotation_session_id)
|
||||
REFERENCES key_rotation_sessions(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||
VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
VALUES(1,27,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
|
||||
+37
@@ -42,8 +42,25 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetMyBlockchain_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStart_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationFinishChain_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationRotatePda_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStatus_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAbort_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationContinue_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||||
@@ -191,6 +208,16 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
||||
Map.entry("GetMyBlockchain", new Net_GetMyBlockchain_Handler()),
|
||||
|
||||
// --- key rotation ---
|
||||
Map.entry("KeyRotationStart", new Net_KeyRotationStart_Handler()),
|
||||
Map.entry("KeyRotationStatus", new Net_KeyRotationStatus_Handler()),
|
||||
Map.entry("KeyRotationAddBlock", new Net_KeyRotationAddBlock_Handler()),
|
||||
Map.entry("KeyRotationFinishChain", new Net_KeyRotationFinishChain_Handler()),
|
||||
Map.entry("KeyRotationRotatePda", new Net_KeyRotationRotatePda_Handler()),
|
||||
Map.entry("KeyRotationContinue", new Net_KeyRotationContinue_Handler()),
|
||||
Map.entry("KeyRotationAbort", new Net_KeyRotationAbort_Handler()),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||
@@ -283,6 +310,16 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||||
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
||||
Map.entry("GetMyBlockchain", Net_GetMyBlockchain_Request.class),
|
||||
|
||||
// --- key rotation ---
|
||||
Map.entry("KeyRotationStart", Net_KeyRotationStart_Request.class),
|
||||
Map.entry("KeyRotationStatus", Net_KeyRotationStatus_Request.class),
|
||||
Map.entry("KeyRotationAddBlock", Net_KeyRotationAddBlock_Request.class),
|
||||
Map.entry("KeyRotationFinishChain", Net_KeyRotationFinishChain_Request.class),
|
||||
Map.entry("KeyRotationRotatePda", Net_KeyRotationRotatePda_Request.class),
|
||||
Map.entry("KeyRotationContinue", Net_KeyRotationContinue_Request.class),
|
||||
Map.entry("KeyRotationAbort", Net_KeyRotationAbort_Request.class),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
||||
|
||||
+31
@@ -98,6 +98,18 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||
lock.lock();
|
||||
try {
|
||||
try {
|
||||
if (isKeyRotationActive(blockchainName)) {
|
||||
BlockchainStateEntry currentState = stateDAO.getByBlockchainName(blockchainName);
|
||||
int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1;
|
||||
String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : "";
|
||||
return error(req, 423, "key_rotation_in_progress", lastNum, lastHash);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("AddBlock: не удалось проверить key rotation status для {}", blockchainName, e);
|
||||
return error(req, WireCodes.Status.INTERNAL_ERROR, "key_rotation_check_failed", -1, "");
|
||||
}
|
||||
|
||||
AddBlockResult r = addBlock(
|
||||
blockchainName,
|
||||
req.getBlockNumber(), // старое поле, пока оставляем
|
||||
@@ -152,6 +164,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return resp;
|
||||
}
|
||||
|
||||
private boolean isKeyRotationActive(String blockchainName) throws java.sql.SQLException {
|
||||
String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName);
|
||||
if (login == null || login.isBlank()) return false;
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT rotation_status
|
||||
FROM solana_user_pda_current
|
||||
WHERE normalized_login = LOWER(BTRIM(?))
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() && !"NONE".equals(rs.getString(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String humanMessage(String code) {
|
||||
if (code == null) return "Ошибка добавления блока";
|
||||
return switch (code) {
|
||||
@@ -184,6 +213,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
|
||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||
case "key_rotation_in_progress" -> "Сейчас выполняется смена ключей; обычные новые блоки временно запрещены";
|
||||
case "key_rotation_check_failed" -> "Не удалось проверить состояние смены ключей";
|
||||
default -> "Ошибка: " + code;
|
||||
};
|
||||
}
|
||||
|
||||
+98
@@ -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();
|
||||
}
|
||||
}
|
||||
+20
@@ -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; }
|
||||
}
|
||||
+64
@@ -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; }
|
||||
}
|
||||
}
|
||||
+9
-6
@@ -105,7 +105,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
||||
String order = asc ? "ASC" : "DESC";
|
||||
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
|
||||
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
|
||||
to_login, to_bch_name, to_block_number, to_block_hash
|
||||
FROM blocks
|
||||
WHERE login = ? AND msg_type = ?
|
||||
ORDER BY block_number
|
||||
@@ -132,9 +133,9 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
||||
item.setLikedByMe(false);
|
||||
item.setRepliesCount(0);
|
||||
item.setRatingsCount(0);
|
||||
item.setTargetBlockchainName(statusBody.toBchName());
|
||||
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber());
|
||||
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes()));
|
||||
item.setTargetBlockchainName(rs.getString("to_bch_name"));
|
||||
item.setTargetBlockNumber((Integer) rs.getObject("to_block_number"));
|
||||
item.setTargetBlockHash(ChannelsReadSupport.toHex(rs.getBytes("to_block_hash")));
|
||||
|
||||
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
|
||||
c,
|
||||
@@ -148,7 +149,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
||||
item.setVersionsTotal(versions.size());
|
||||
item.setText(versions.get(versions.size() - 1).getText());
|
||||
|
||||
fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes());
|
||||
fillTargetDetails(c, item, rs.getString("to_bch_name"),
|
||||
(Integer) rs.getObject("to_block_number"), rs.getBytes("to_block_hash"));
|
||||
out.add(item);
|
||||
}
|
||||
}
|
||||
@@ -215,7 +217,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
||||
return;
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
|
||||
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
|
||||
to_login, to_bch_name, to_block_number, to_block_hash
|
||||
FROM blocks
|
||||
WHERE bch_name = ? AND block_number = ?
|
||||
LIMIT 1
|
||||
|
||||
+120
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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", "Не удалось прервать смену ключей");
|
||||
}
|
||||
}
|
||||
}
|
||||
+224
@@ -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');
|
||||
}
|
||||
}
|
||||
+53
@@ -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",
|
||||
"Не удалось продолжить смену ключей");
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||
|
||||
import blockchain.BchBlockEntry;
|
||||
import blockchain.BchCryptoVerifier;
|
||||
import blockchain.MsgSubType;
|
||||
import blockchain.body.ForkBody;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||
import shine.db.dao.KeyRotationSessionsDAO;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||
import shine.db.entities.KeyRotationSessionEntry;
|
||||
import shine.db.entities.KeyRotationStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* Проверяет, что candidate-chain полностью сохранена и опубликована в Arweave/Turbo,
|
||||
* затем переводит ротацию COPYING_CHAIN -> CHAIN_READY.
|
||||
*/
|
||||
public final class Net_KeyRotationFinishChain_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_KeyRotationFinishChain_Handler.class);
|
||||
|
||||
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
|
||||
private final BlocksDAO blocks = BlocksDAO.getInstance();
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||
Net_KeyRotationFinishChain_Request req = (Net_KeyRotationFinishChain_Request) baseReq;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||
}
|
||||
String login = ctx.getLogin().trim();
|
||||
|
||||
KeyRotationSessionEntry initial;
|
||||
try {
|
||||
initial = rotations.getActiveByLogin(login);
|
||||
} catch (Exception e) {
|
||||
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_DB_ERROR", "Не удалось прочитать состояние смены ключей");
|
||||
}
|
||||
if (initial == null) {
|
||||
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
|
||||
}
|
||||
if (initial.getStatus() == KeyRotationStatus.CHAIN_READY) {
|
||||
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), initial);
|
||||
}
|
||||
if (initial.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
|
||||
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация не находится на этапе COPYING_CHAIN");
|
||||
}
|
||||
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(initial.getCandidateBlockchainName());
|
||||
lock.lock();
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
KeyRotationSessionEntry session = rotations.getActiveByLogin(c, login);
|
||||
if (session == null) {
|
||||
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
|
||||
}
|
||||
if (session.getStatus() == KeyRotationStatus.CHAIN_READY) {
|
||||
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), session);
|
||||
}
|
||||
if (session.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
|
||||
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация больше не находится на этапе COPYING_CHAIN");
|
||||
}
|
||||
|
||||
int expected = session.getProgressTotal();
|
||||
int stored = candidates.countStored(c, session.getId());
|
||||
int published = candidates.countPublished(c, session.getId());
|
||||
if (stored != expected) {
|
||||
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_INCOMPLETE",
|
||||
"Candidate-chain ещё не полностью загружена: " + stored + "/" + expected);
|
||||
}
|
||||
if (published != expected) {
|
||||
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_NOT_PUBLISHED",
|
||||
"Candidate-chain ещё не полностью опубликована в Arweave/Turbo: " + published + "/" + expected);
|
||||
}
|
||||
|
||||
List<KeyRotationCandidateBlockEntry> entries = candidates.listBySession(c, session.getId());
|
||||
String validationError = validateCompleteChain(c, session, entries);
|
||||
if (validationError != null) {
|
||||
try { rotations.recordError(session.getId(), validationError); } catch (Exception ignored) { }
|
||||
return NetExceptionResponseFactory.error(req, 409, validationError, "Финальная проверка candidate-chain не пройдена");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_FINISH_CHAIN_FAILED", "Не удалось завершить проверку candidate-chain");
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
try {
|
||||
KeyRotationSessionEntry ready = rotations.transition(
|
||||
initial.getId(), KeyRotationStatus.COPYING_CHAIN, KeyRotationStatus.CHAIN_READY);
|
||||
rotations.clearError(initial.getId());
|
||||
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), ready);
|
||||
} catch (Exception transitionFailure) {
|
||||
log.warn("KeyRotationFinishChain transition failed: login={}, sessionId={}",
|
||||
login, initial.getId(), transitionFailure);
|
||||
// Идемпотентность для параллельного FinishChain из другой сессии.
|
||||
try {
|
||||
KeyRotationSessionEntry now = rotations.getActiveByLogin(login);
|
||||
if (now != null && now.getStatus() == KeyRotationStatus.CHAIN_READY) {
|
||||
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), now);
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_FINISH_CHAIN_RACE", "Состояние ротации изменилось во время FinishChain");
|
||||
}
|
||||
}
|
||||
|
||||
private String validateCompleteChain(Connection c,
|
||||
KeyRotationSessionEntry session,
|
||||
List<KeyRotationCandidateBlockEntry> entries) throws Exception {
|
||||
if (entries == null || entries.size() != session.getProgressTotal()) return "KEY_ROTATION_CHAIN_INCOMPLETE";
|
||||
if (session.getProgressTotal() != session.getForkFromBlock() + 2) return "KEY_ROTATION_BAD_PROGRESS_TOTAL";
|
||||
|
||||
byte[] newBlockchainKey = Base64.getDecoder().decode(session.getNewBlockchainKey());
|
||||
if (newBlockchainKey.length != 32) return "KEY_ROTATION_BAD_STORED_KEY";
|
||||
|
||||
BchBlockEntry previous = null;
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
KeyRotationCandidateBlockEntry stored = entries.get(i);
|
||||
if (stored.getBlockNumber() != i) return "KEY_ROTATION_CANDIDATE_GAP";
|
||||
if (stored.isArweavePublishPending() || stored.getArweavePublishedAtMs() == null) {
|
||||
return "KEY_ROTATION_CHAIN_NOT_PUBLISHED";
|
||||
}
|
||||
|
||||
BchBlockEntry candidate;
|
||||
try {
|
||||
candidate = new BchBlockEntry(stored.getBlockBytes());
|
||||
candidate.body.check();
|
||||
} catch (Exception e) {
|
||||
return "KEY_ROTATION_BAD_STORED_BLOCK";
|
||||
}
|
||||
if (candidate.blockNumber != i) return "KEY_ROTATION_BLOCK_NUMBER_MISMATCH";
|
||||
if (!Arrays.equals(candidate.getHash32(), stored.getBlockHash())) return "KEY_ROTATION_STORED_HASH_MISMATCH";
|
||||
if (!Arrays.equals(candidate.getDataItemId32(), stored.getDataItemId())) return "KEY_ROTATION_STORED_DATAITEM_MISMATCH";
|
||||
if (!candidate.getDataItem().hasTag("App", "test5590")) return "KEY_ROTATION_BAD_APP_TAG";
|
||||
if (!BchCryptoVerifier.verifyBlock(candidate, newBlockchainKey)) return "KEY_ROTATION_BAD_SIGNATURE";
|
||||
|
||||
if (i == 0) {
|
||||
if (!isZero32(candidate.prevHash32)) return "KEY_ROTATION_GENESIS_PREV_HASH";
|
||||
} else {
|
||||
if (previous == null || !Arrays.equals(candidate.prevHash32, previous.getHash32())) {
|
||||
return "KEY_ROTATION_CHAIN_HASH_MISMATCH";
|
||||
}
|
||||
}
|
||||
|
||||
String semanticError = validateAgainstRotation(c, session, candidate);
|
||||
if (semanticError != null) return semanticError;
|
||||
previous = candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateAgainstRotation(Connection c, KeyRotationSessionEntry session, BchBlockEntry candidate) throws Exception {
|
||||
if (candidate.blockNumber <= session.getForkFromBlock()) {
|
||||
BlockEntry sourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), candidate.blockNumber);
|
||||
if (sourceEntry == null || sourceEntry.getBlockBytes() == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
|
||||
BchBlockEntry source = new BchBlockEntry(sourceEntry.getBlockBytes());
|
||||
if (!Arrays.equals(source.getFrameBytes(), candidate.getFrameBytes())) return "KEY_ROTATION_FRAME_MISMATCH";
|
||||
if (!Arrays.equals(source.getDataItem().rawTags(), candidate.getDataItem().rawTags())) return "KEY_ROTATION_TAGS_MISMATCH";
|
||||
if (!Arrays.equals(source.getDataItem().target(), candidate.getDataItem().target())) return "KEY_ROTATION_TARGET_MISMATCH";
|
||||
if (!Arrays.equals(source.getDataItem().anchor(), candidate.getDataItem().anchor())) return "KEY_ROTATION_ANCHOR_MISMATCH";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidate.blockNumber != session.getForkFromBlock() + 1) return "KEY_ROTATION_UNEXPECTED_BLOCK_NUMBER";
|
||||
if ((candidate.type & 0xFFFF) != 0
|
||||
|| (candidate.subType & 0xFFFF) != (MsgSubType.TECH_FORK & 0xFFFF)
|
||||
|| !(candidate.body instanceof ForkBody fork)) {
|
||||
return "KEY_ROTATION_TECH_FORK_REQUIRED";
|
||||
}
|
||||
if (!Arrays.equals(candidate.prevHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_PREV_HASH";
|
||||
|
||||
BlockEntry forkSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getForkFromBlock());
|
||||
BlockEntry tipSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getSourceTipBlock());
|
||||
if (forkSourceEntry == null || tipSourceEntry == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
|
||||
BchBlockEntry forkSource = new BchBlockEntry(forkSourceEntry.getBlockBytes());
|
||||
BchBlockEntry tipSource = new BchBlockEntry(tipSourceEntry.getBlockBytes());
|
||||
|
||||
byte[] oldBlockchain = Base64.getDecoder().decode(session.getOldBlockchainKey());
|
||||
if (!Arrays.equals(fork.parentBlockchainKey32, oldBlockchain)) return "KEY_ROTATION_TECH_FORK_PARENT_KEY";
|
||||
if (fork.forkPointBlockNumber != session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_POINT";
|
||||
if (!Arrays.equals(fork.forkPointBlockHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_POINT_HASH";
|
||||
if (fork.forkPointTimestampMs != Math.multiplyExact(forkSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_POINT_TIME";
|
||||
if (fork.parentTipBlockNumber != session.getSourceTipBlock()) return "KEY_ROTATION_TECH_FORK_TIP";
|
||||
if (!Arrays.equals(fork.parentTipBlockHash32, session.getSourceTipHash())) return "KEY_ROTATION_TECH_FORK_TIP_HASH";
|
||||
if (fork.parentTipTimestampMs != Math.multiplyExact(tipSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_TIP_TIME";
|
||||
if (fork.discardedBlocksCount != session.getSourceTipBlock() - session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_DISCARDED";
|
||||
if (fork.reasonCode != session.getReasonCode()) return "KEY_ROTATION_TECH_FORK_REASON";
|
||||
if (!normalizeComment(fork.comment).equals(normalizeComment(session.getComment()))) return "KEY_ROTATION_TECH_FORK_COMMENT";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isZero32(byte[] value) {
|
||||
if (value == null || value.length != 32) return false;
|
||||
for (byte b : value) if (b != 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String normalizeComment(String value) {
|
||||
if (value == null) return "";
|
||||
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||
}
|
||||
}
|
||||
+83
@@ -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;
|
||||
}
|
||||
}
|
||||
+209
@@ -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) { }
|
||||
}
|
||||
+29
@@ -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",
|
||||
"Не удалось прочитать состояние смены ключей");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -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 {
|
||||
}
|
||||
+17
@@ -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; }
|
||||
}
|
||||
+7
@@ -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 {
|
||||
}
|
||||
+7
@@ -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 {
|
||||
}
|
||||
+14
@@ -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; }
|
||||
}
|
||||
+32
@@ -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; }
|
||||
}
|
||||
+83
@@ -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; }
|
||||
}
|
||||
+7
@@ -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 {
|
||||
}
|
||||
+180
-2
@@ -216,10 +216,188 @@ public final class ShineUsersCodec {
|
||||
|
||||
int formatMajor = raw[5] & 0xFF;
|
||||
int formatMinor = raw[6] & 0xFF;
|
||||
if (formatMajor != 1 || formatMinor != 2) {
|
||||
if (formatMajor != 1) {
|
||||
throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor);
|
||||
}
|
||||
return parseUserPdaAccountV12(pdaAddress, slot, rawDataBase64, lastTxSignature, raw);
|
||||
if (formatMinor == 2) {
|
||||
return parseUserPdaAccountV12(pdaAddress, slot, rawDataBase64, lastTxSignature, raw);
|
||||
}
|
||||
if (formatMinor == 0) {
|
||||
// Временная read-only совместимость: legacy PDA 1.0 снова читается
|
||||
// server-sync только для bootstrap старых server-записей. Новые записи
|
||||
// и update по-прежнему работают только с текущим форматом 1.2.
|
||||
return parseUserPdaAccountV10(pdaAddress, slot, rawDataBase64, lastTxSignature, raw);
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor);
|
||||
}
|
||||
|
||||
private static UserPdaSnapshot parseUserPdaAccountV10(
|
||||
String pdaAddress,
|
||||
long slot,
|
||||
String rawDataBase64,
|
||||
String lastTxSignature,
|
||||
byte[] raw
|
||||
) {
|
||||
int recordLen = u16le(raw, 7);
|
||||
if (recordLen < 9 + 64 || recordLen > raw.length) {
|
||||
throw new IllegalArgumentException("Invalid PDA 1.0 record length");
|
||||
}
|
||||
|
||||
byte[] useful = new byte[recordLen];
|
||||
System.arraycopy(raw, 0, useful, 0, recordLen);
|
||||
Reader reader = new Reader(useful);
|
||||
reader.skip(9);
|
||||
|
||||
long createdAtMs = reader.readU64();
|
||||
long updatedAtMs = reader.readU64();
|
||||
int recordNumber = (int) reader.readU32();
|
||||
String prevHash = toHex(reader.readFixed(32));
|
||||
String login = reader.readStringU8();
|
||||
int blocksCount = reader.readU8();
|
||||
|
||||
String recoveryKey = null;
|
||||
String rootKey = null;
|
||||
String clientKey = null;
|
||||
String blockchainName = null;
|
||||
String blockchainKey = null;
|
||||
long paidLimitBytes = 0L;
|
||||
long usedBytes = 0L;
|
||||
int lastBlockNumber = 0;
|
||||
String lastBlockHash = "";
|
||||
String lastBlockSignature = "";
|
||||
String arweaveTxId = "";
|
||||
String archiveHeadTxId = "";
|
||||
String archiveHeadHash = "";
|
||||
boolean isServer = false;
|
||||
int addressFormatType = 0;
|
||||
int addressFormatVersion = 0;
|
||||
String serverAddress = "";
|
||||
List<String> syncServers = new ArrayList<>();
|
||||
List<String> accessServers = new ArrayList<>();
|
||||
int sessionsMode = 1;
|
||||
List<UserSessionSnapshot> sessions = new ArrayList<>();
|
||||
int trustedCount = 0;
|
||||
|
||||
boolean seenRecovery = false;
|
||||
boolean seenRoot = false;
|
||||
boolean seenClient = false;
|
||||
boolean seenBlockchain = false;
|
||||
|
||||
for (int i = 0; i < blocksCount; i++) {
|
||||
int blockType = reader.readU8();
|
||||
int blockVersion = reader.readU8();
|
||||
if (blockVersion != BLOCK_VERSION_0) {
|
||||
throw new IllegalArgumentException("Unsupported PDA 1.0 block version: type=" + blockType + " version=" + blockVersion);
|
||||
}
|
||||
|
||||
switch (blockType) {
|
||||
case BLOCK_TYPE_RECOVERY_KEY -> {
|
||||
if (seenRecovery) throw new IllegalArgumentException("Duplicate RecoveryKeyBlock in PDA 1.0");
|
||||
recoveryKey = Base58Util.encode(reader.readFixed(32));
|
||||
seenRecovery = true;
|
||||
}
|
||||
case BLOCK_TYPE_ROOT_KEY -> {
|
||||
if (seenRoot) throw new IllegalArgumentException("Duplicate RootKeyBlock in PDA 1.0");
|
||||
rootKey = Base58Util.encode(reader.readFixed(32));
|
||||
seenRoot = true;
|
||||
}
|
||||
case BLOCK_TYPE_CLIENT_KEY -> {
|
||||
if (seenClient) throw new IllegalArgumentException("Duplicate ClientKeyBlock in PDA 1.0");
|
||||
clientKey = Base58Util.encode(reader.readFixed(32));
|
||||
seenClient = true;
|
||||
}
|
||||
case BLOCK_TYPE_BLOCKCHAIN_REGISTRY -> {
|
||||
if (seenBlockchain) throw new IllegalArgumentException("Duplicate BlockchainRegistryBlock in PDA 1.0");
|
||||
int count = reader.readU8();
|
||||
if (count < 1) throw new IllegalArgumentException("Empty BlockchainRegistryBlock in PDA 1.0");
|
||||
for (int j = 0; j < count; j++) {
|
||||
int blockchainType = reader.readU8();
|
||||
String currentName = reader.readStringU8();
|
||||
String currentKey = Base58Util.encode(reader.readFixed(32));
|
||||
long currentPaidLimit = reader.readU64();
|
||||
long currentUsedBytes = reader.readU64();
|
||||
long currentLastBlockNumber = reader.readU32();
|
||||
String currentLastBlockHash = toHex(reader.readFixed(32));
|
||||
String currentLastBlockSignature = Base58Util.encode(reader.readFixed(64));
|
||||
int arweavePresent = reader.readU8();
|
||||
String currentArweaveTxId = arweavePresent == 1 ? reader.readStringU8() : "";
|
||||
if (arweavePresent != 0 && arweavePresent != 1) {
|
||||
throw new IllegalArgumentException("Invalid PDA 1.0 arweave_present");
|
||||
}
|
||||
|
||||
// Runtime SHiNE использует основной пользовательский blockchain (type=1).
|
||||
// Если legacy запись содержит несколько chain records, берём первый MAIN_USER.
|
||||
if (blockchainName == null && blockchainType == BLOCKCHAIN_TYPE_MAIN_USER) {
|
||||
blockchainName = currentName;
|
||||
blockchainKey = currentKey;
|
||||
paidLimitBytes = currentPaidLimit;
|
||||
usedBytes = currentUsedBytes;
|
||||
lastBlockNumber = Math.toIntExact(currentLastBlockNumber);
|
||||
lastBlockHash = currentLastBlockHash;
|
||||
lastBlockSignature = currentLastBlockSignature;
|
||||
arweaveTxId = currentArweaveTxId;
|
||||
}
|
||||
}
|
||||
seenBlockchain = true;
|
||||
}
|
||||
case BLOCK_TYPE_SERVER_PROFILE -> {
|
||||
int serverFlag = reader.readU8();
|
||||
if (serverFlag != 0 && serverFlag != 1) {
|
||||
throw new IllegalArgumentException("Invalid PDA 1.0 is_server");
|
||||
}
|
||||
isServer = serverFlag == 1;
|
||||
if (isServer) {
|
||||
addressFormatType = reader.readU8();
|
||||
addressFormatVersion = reader.readU8();
|
||||
serverAddress = reader.readStringU8();
|
||||
int count = reader.readU8();
|
||||
for (int j = 0; j < count; j++) syncServers.add(reader.readStringU8());
|
||||
}
|
||||
}
|
||||
case BLOCK_TYPE_ACCESS_SERVERS -> {
|
||||
int count = reader.readU8();
|
||||
for (int j = 0; j < count; j++) accessServers.add(reader.readStringU8());
|
||||
}
|
||||
case BLOCK_TYPE_SESSIONS -> {
|
||||
sessionsMode = reader.readU8();
|
||||
int count = reader.readU8();
|
||||
for (int j = 0; j < count; j++) {
|
||||
int sessionType = reader.readU8();
|
||||
int sessionVersion = reader.readU8();
|
||||
String sessionName = reader.readStringU8();
|
||||
String sessionPubKey = Base58Util.encode(reader.readFixed(32));
|
||||
sessions.add(new UserSessionSnapshot(sessionType, sessionVersion, sessionName, sessionPubKey));
|
||||
}
|
||||
}
|
||||
case BLOCK_TYPE_TRUSTED_STATE -> trustedCount = reader.readU8();
|
||||
case BLOCK_TYPE_ARCHIVE_HEAD -> {
|
||||
archiveHeadTxId = Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32));
|
||||
archiveHeadHash = toHex(reader.readFixed(32));
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Unsupported PDA 1.0 block type: " + blockType);
|
||||
}
|
||||
}
|
||||
|
||||
String signature = Base58Util.encode(reader.readFixed(64));
|
||||
if (reader.remaining() != 0) {
|
||||
throw new IllegalArgumentException("Unexpected tail inside PDA 1.0 record");
|
||||
}
|
||||
if (!seenRecovery || !seenRoot || !seenClient || !seenBlockchain
|
||||
|| recoveryKey == null || rootKey == null || clientKey == null
|
||||
|| blockchainName == null || blockchainKey == null) {
|
||||
throw new IllegalArgumentException("PDA 1.0 misses required blocks");
|
||||
}
|
||||
|
||||
return new UserPdaSnapshot(
|
||||
pdaAddress, login, recordNumber, slot, lastTxSignature,
|
||||
recoveryKey, rootKey, clientKey, blockchainName, blockchainKey, paidLimitBytes,
|
||||
usedBytes, lastBlockNumber, lastBlockHash, lastBlockSignature, arweaveTxId,
|
||||
archiveHeadTxId, archiveHeadHash,
|
||||
isServer, addressFormatType, addressFormatVersion, serverAddress,
|
||||
List.copyOf(syncServers), List.copyOf(accessServers), sessionsMode, List.copyOf(sessions), trustedCount,
|
||||
createdAtMs, updatedAtMs, prevHash, signature, rawDataBase64,
|
||||
List.of(new BlockchainForkSnapshot(0, blockchainKey, createdAtMs, paidLimitBytes))
|
||||
);
|
||||
}
|
||||
|
||||
private static ParsedInstruction parseV12PdaMutationInstruction(
|
||||
|
||||
+95
@@ -1,5 +1,6 @@
|
||||
package sync.storage.postgres;
|
||||
|
||||
import sync.util.Base58Util;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
@@ -272,6 +273,11 @@ public final class PostgresStorageRepository
|
||||
snapshots
|
||||
);
|
||||
|
||||
reconcileConfirmedKeyRotations(
|
||||
connection,
|
||||
snapshots
|
||||
);
|
||||
|
||||
upsertSyncState(
|
||||
connection,
|
||||
newState
|
||||
@@ -305,6 +311,11 @@ public final class PostgresStorageRepository
|
||||
snapshots
|
||||
);
|
||||
|
||||
reconcileConfirmedKeyRotations(
|
||||
connection,
|
||||
snapshots
|
||||
);
|
||||
|
||||
upsertSyncState(
|
||||
connection,
|
||||
newState
|
||||
@@ -536,6 +547,90 @@ public final class PostgresStorageRepository
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Solana является источником истины для момента завершения ротации PDA.
|
||||
* После upsert current-снимка сверяем все три новых public key и новый blockchainName.
|
||||
* Совпадение переводит локальную машину ROTATING_PDA -> PDA_ROTATED в той же DB-транзакции.
|
||||
*/
|
||||
private void reconcileConfirmedKeyRotations(
|
||||
Connection connection,
|
||||
List<ShineUsersCodec.UserPdaSnapshot> snapshots
|
||||
) throws Exception {
|
||||
if (snapshots == null || snapshots.isEmpty()) return;
|
||||
|
||||
String select = """
|
||||
SELECT id, new_root_key, new_blockchain_key, new_client_key, candidate_blockchain_name
|
||||
FROM key_rotation_sessions
|
||||
WHERE login = ? AND status IN ('CHAIN_READY', 'ROTATING_PDA')
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
""";
|
||||
|
||||
for (ShineUsersCodec.UserPdaSnapshot snapshot : snapshots) {
|
||||
if (snapshot == null || snapshot.login() == null || snapshot.login().isBlank()) continue;
|
||||
|
||||
try (PreparedStatement ps = connection.prepareStatement(select)) {
|
||||
ps.setString(1, snapshot.login());
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) continue;
|
||||
|
||||
long rotationId = rs.getLong("id");
|
||||
String expectedRoot = rs.getString("new_root_key");
|
||||
String expectedBlockchain = rs.getString("new_blockchain_key");
|
||||
String expectedClient = rs.getString("new_client_key");
|
||||
String expectedBlockchainName = rs.getString("candidate_blockchain_name");
|
||||
|
||||
String actualRoot = keyToBase64(snapshot.rootKey());
|
||||
String actualBlockchain = keyToBase64(snapshot.blockchainKey());
|
||||
String actualClient = keyToBase64(snapshot.clientKey());
|
||||
|
||||
if (!expectedRoot.equals(actualRoot)
|
||||
|| !expectedBlockchain.equals(actualBlockchain)
|
||||
|| !expectedClient.equals(actualClient)
|
||||
|| !expectedBlockchainName.equals(snapshot.blockchainName())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
try (PreparedStatement update = connection.prepareStatement("""
|
||||
UPDATE key_rotation_sessions
|
||||
SET status = 'PDA_ROTATED',
|
||||
pda_rotation_signature = COALESCE(pda_rotation_signature, ?),
|
||||
updated_at_ms = ?,
|
||||
last_error = NULL,
|
||||
last_error_at_ms = NULL
|
||||
WHERE id = ? AND status IN ('CHAIN_READY', 'ROTATING_PDA')
|
||||
""")) {
|
||||
update.setString(1, snapshot.lastTxSignature() == null || snapshot.lastTxSignature().isBlank() ? null : snapshot.lastTxSignature());
|
||||
update.setLong(2, now);
|
||||
update.setLong(3, rotationId);
|
||||
if (update.executeUpdate() != 1) continue;
|
||||
}
|
||||
try (PreparedStatement update = connection.prepareStatement("""
|
||||
UPDATE solana_user_pda_current
|
||||
SET rotation_status = 'PDA_ROTATED', rotation_session_id = ?
|
||||
WHERE login = ? AND rotation_session_id = ? AND rotation_status IN ('CHAIN_READY', 'ROTATING_PDA')
|
||||
""")) {
|
||||
update.setLong(1, rotationId);
|
||||
update.setString(2, snapshot.login());
|
||||
update.setLong(3, rotationId);
|
||||
if (update.executeUpdate() != 1) {
|
||||
throw new SQLException("Rotation session is not attached to synced login=" + snapshot.login());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String keyToBase64(String base58) {
|
||||
if (base58 == null || base58.isBlank()) return "";
|
||||
byte[] decoded = Base58Util.decode(base58);
|
||||
if (decoded.length != 32) return "";
|
||||
return java.util.Base64.getEncoder().encodeToString(decoded);
|
||||
}
|
||||
|
||||
private void bindSnapshot(
|
||||
PreparedStatement statement,
|
||||
ShineUsersCodec.UserPdaSnapshot snapshot,
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package sync.codec;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ShineUsersCodecLegacyV10Test {
|
||||
|
||||
@Test
|
||||
void readsLegacyV10ServerPdaForBootstrap() {
|
||||
byte[] raw = buildLegacyServerPda();
|
||||
ShineUsersCodec.UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(
|
||||
"legacy-pda", 123L, Base64.getEncoder().encodeToString(raw), "legacy-tx"
|
||||
);
|
||||
|
||||
assertEquals("legacy_server", snapshot.login());
|
||||
assertEquals("legacy_server-001", snapshot.blockchainName());
|
||||
assertEquals(100_000L, snapshot.paidLimitBytes());
|
||||
assertEquals(12_345L, snapshot.usedBytes());
|
||||
assertEquals(7, snapshot.lastBlockNumber());
|
||||
assertTrue(snapshot.isServer());
|
||||
assertEquals(1, snapshot.addressFormatType());
|
||||
assertEquals(0, snapshot.addressFormatVersion());
|
||||
assertEquals("wss://legacy.example/ws", snapshot.serverAddress());
|
||||
assertEquals(java.util.List.of("sync-old"), snapshot.syncServers());
|
||||
assertEquals(java.util.List.of("access-old"), snapshot.accessServers());
|
||||
assertEquals(1, snapshot.blockchainForks().size());
|
||||
assertEquals(snapshot.blockchainKey(), snapshot.blockchainForks().get(0).blockchainKey());
|
||||
}
|
||||
|
||||
private static byte[] buildLegacyServerPda() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
bytes(out, "SHiNE".getBytes(StandardCharsets.UTF_8));
|
||||
u8(out, 1);
|
||||
u8(out, 0);
|
||||
u16(out, 0); // record_len patch later
|
||||
u64(out, 1_700_000_000_000L);
|
||||
u64(out, 1_700_000_001_000L);
|
||||
u32(out, 3);
|
||||
bytes(out, repeat(0x11, 32));
|
||||
str(out, "legacy_server");
|
||||
u8(out, 7); // recovery, root, client, blockchain, server, access, trusted
|
||||
|
||||
u8(out, 0); u8(out, 0); bytes(out, repeat(0x21, 32));
|
||||
u8(out, 1); u8(out, 0); bytes(out, repeat(0x22, 32));
|
||||
u8(out, 2); u8(out, 0); bytes(out, repeat(0x23, 32));
|
||||
|
||||
u8(out, 3); u8(out, 0);
|
||||
u8(out, 1);
|
||||
u8(out, 1);
|
||||
str(out, "legacy_server-001");
|
||||
bytes(out, repeat(0x24, 32));
|
||||
u64(out, 100_000L);
|
||||
u64(out, 12_345L);
|
||||
u32(out, 7);
|
||||
bytes(out, repeat(0x25, 32));
|
||||
bytes(out, repeat(0x26, 64));
|
||||
u8(out, 1);
|
||||
str(out, "legacy-arweave-tx");
|
||||
|
||||
u8(out, 30); u8(out, 0);
|
||||
u8(out, 1);
|
||||
u8(out, 1);
|
||||
u8(out, 0);
|
||||
str(out, "wss://legacy.example/ws");
|
||||
u8(out, 1);
|
||||
str(out, "sync-old");
|
||||
|
||||
u8(out, 40); u8(out, 0);
|
||||
u8(out, 1);
|
||||
str(out, "access-old");
|
||||
|
||||
u8(out, 70); u8(out, 0); u8(out, 2);
|
||||
|
||||
bytes(out, repeat(0x27, 64));
|
||||
|
||||
byte[] record = out.toByteArray();
|
||||
record[7] = (byte) (record.length & 0xff);
|
||||
record[8] = (byte) ((record.length >>> 8) & 0xff);
|
||||
return record;
|
||||
}
|
||||
|
||||
private static byte[] repeat(int value, int count) {
|
||||
byte[] bytes = new byte[count];
|
||||
java.util.Arrays.fill(bytes, (byte) value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void str(ByteArrayOutputStream out, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
u8(out, bytes.length);
|
||||
bytes(out, bytes);
|
||||
}
|
||||
|
||||
private static void bytes(ByteArrayOutputStream out, byte[] value) {
|
||||
out.writeBytes(value);
|
||||
}
|
||||
|
||||
private static void u8(ByteArrayOutputStream out, long value) {
|
||||
out.write((int) value & 0xff);
|
||||
}
|
||||
|
||||
private static void u16(ByteArrayOutputStream out, long value) {
|
||||
u8(out, value);
|
||||
u8(out, value >>> 8);
|
||||
}
|
||||
|
||||
private static void u32(ByteArrayOutputStream out, long value) {
|
||||
for (int i = 0; i < 4; i++) u8(out, value >>> (8 * i));
|
||||
}
|
||||
|
||||
private static void u64(ByteArrayOutputStream out, long value) {
|
||||
for (int i = 0; i < 8; i++) u8(out, value >>> (8 * i));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package server.keyrotation;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Небольшой background worker, который продолжает rebuild даже если UI был закрыт. */
|
||||
public final class KeyRotationRebuildScheduler {
|
||||
private static final Logger log = LoggerFactory.getLogger(KeyRotationRebuildScheduler.class);
|
||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||
private static final KeyRotationRebuildService SERVICE = new KeyRotationRebuildService();
|
||||
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
|
||||
@Override public Thread newThread(Runnable r) {
|
||||
Thread t = new Thread(r, "key-rotation-rebuild");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}
|
||||
});
|
||||
|
||||
private KeyRotationRebuildScheduler() { }
|
||||
|
||||
public static void startOrLog() {
|
||||
if (!STARTED.compareAndSet(false, true)) return;
|
||||
EXECUTOR.scheduleWithFixedDelay(() -> {
|
||||
try { SERVICE.runReady(4); }
|
||||
catch (Exception e) { log.warn("Key rotation rebuild cycle failed", e); }
|
||||
}, 1, 2, TimeUnit.SECONDS);
|
||||
log.info("Key rotation rebuild scheduler started");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package server.keyrotation;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||
import server.sync.BlockchainResyncGuard;
|
||||
import shine.db.dao.BlockchainResyncCleanupDAO;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||
import shine.db.dao.KeyRotationSessionsDAO;
|
||||
import shine.db.dao.SolanaUserPdaCurrentDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||
import shine.db.entities.KeyRotationSessionEntry;
|
||||
import shine.db.entities.KeyRotationStatus;
|
||||
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* Автоматически materialize-ит candidate fork после того, как Solana sync подтвердил новое PDA.
|
||||
* Candidate-блоки уже опубликованы в Arweave; здесь они только становятся единственной рабочей
|
||||
* цепочкой PostgreSQL и повторно проходят обычный AddBlock validation/projection path.
|
||||
*/
|
||||
public final class KeyRotationRebuildService {
|
||||
private static final Logger log = LoggerFactory.getLogger(KeyRotationRebuildService.class);
|
||||
|
||||
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
|
||||
private final BlockchainResyncCleanupDAO cleanup = BlockchainResyncCleanupDAO.getInstance();
|
||||
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
|
||||
private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance();
|
||||
private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler();
|
||||
|
||||
public int runReady(int limit) {
|
||||
int processed = 0;
|
||||
try {
|
||||
for (KeyRotationSessionEntry session : rotations.listByStatus(KeyRotationStatus.PDA_ROTATED, limit)) {
|
||||
if (tryRebuild(session)) processed++;
|
||||
}
|
||||
for (KeyRotationSessionEntry session : rotations.listByStatus(KeyRotationStatus.REBUILDING_SERVER, Math.max(1, limit - processed))) {
|
||||
if (tryRebuild(session)) processed++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Key rotation rebuild scan failed", e);
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
|
||||
public boolean tryRebuild(KeyRotationSessionEntry session) {
|
||||
if (session == null) return false;
|
||||
try {
|
||||
KeyRotationSessionEntry current = rotations.getById(session.getId());
|
||||
if (current == null) return false;
|
||||
if (current.getStatus() == KeyRotationStatus.PDA_ROTATED) {
|
||||
current = rotations.transition(current.getId(), KeyRotationStatus.PDA_ROTATED, KeyRotationStatus.REBUILDING_SERVER);
|
||||
}
|
||||
if (current.getStatus() != KeyRotationStatus.REBUILDING_SERVER) return false;
|
||||
rebuild(current);
|
||||
rotations.clearError(current.getId());
|
||||
rotations.transition(current.getId(), KeyRotationStatus.REBUILDING_SERVER, KeyRotationStatus.WALLET_MIGRATION);
|
||||
log.info("Key rotation rebuild complete: login={} {} -> {}", current.getLogin(), current.getSourceBlockchainName(), current.getCandidateBlockchainName());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
try { rotations.recordError(session.getId(), "REBUILDING_SERVER: " + safeMessage(e)); } catch (Exception ignored) { }
|
||||
log.warn("Key rotation rebuild failed: id={} login={}", session.getId(), session.getLogin(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void rebuild(KeyRotationSessionEntry session) throws Exception {
|
||||
verifyCurrentPda(session);
|
||||
List<KeyRotationCandidateBlockEntry> candidateBlocks;
|
||||
try (var c = shine.db.DbController.getInstance().getConnection()) {
|
||||
candidateBlocks = candidates.listBySession(c, session.getId());
|
||||
}
|
||||
if (candidateBlocks.size() != session.getProgressTotal()) {
|
||||
throw new IllegalStateException("candidate count mismatch: " + candidateBlocks.size() + "/" + session.getProgressTotal());
|
||||
}
|
||||
for (KeyRotationCandidateBlockEntry block : candidateBlocks) {
|
||||
if (block.isArweavePublishPending()) {
|
||||
throw new IllegalStateException("candidate block is not published: #" + block.getBlockNumber());
|
||||
}
|
||||
}
|
||||
|
||||
String source = session.getSourceBlockchainName();
|
||||
String target = session.getCandidateBlockchainName();
|
||||
ReentrantLock first = BlockchainLocks.lockFor(source.compareTo(target) <= 0 ? source : target);
|
||||
ReentrantLock second = BlockchainLocks.lockFor(source.compareTo(target) <= 0 ? target : source);
|
||||
first.lock();
|
||||
second.lock();
|
||||
boolean sourceGuard = false;
|
||||
boolean targetGuard = false;
|
||||
try {
|
||||
sourceGuard = BlockchainResyncGuard.tryBegin(source);
|
||||
targetGuard = BlockchainResyncGuard.tryBegin(target);
|
||||
if (!sourceGuard || !targetGuard) {
|
||||
throw new IllegalStateException("another resync/rebuild is active");
|
||||
}
|
||||
|
||||
// Повторный запуск безопасен: сначала убираем любую частично materialized candidate-цепочку.
|
||||
if (states.getByBlockchainName(target) != null) {
|
||||
cleanup.cleanupBlockchainForFullResync(target);
|
||||
}
|
||||
if (states.getByBlockchainName(source) != null) {
|
||||
cleanup.cleanupBlockchainForFullResync(source);
|
||||
}
|
||||
|
||||
createCandidateState(session);
|
||||
BlockchainResyncGuard.withBypass(target, () -> {
|
||||
for (KeyRotationCandidateBlockEntry candidate : candidateBlocks) {
|
||||
Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(target, candidate.getBlockBytes());
|
||||
if (!result.ok()) {
|
||||
throw new IllegalStateException("candidate replay rejected at #" + candidate.getBlockNumber() + ": " + result.reasonCode());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Чужие подписанные ссылки сохраняют логическую identity login+number+hash.
|
||||
// Здесь меняется только их локальный cache физического fork.
|
||||
cleanup.refreshLogicalTargetBlockchainCache(session.getLogin(), source, target);
|
||||
} finally {
|
||||
if (targetGuard) BlockchainResyncGuard.end(target);
|
||||
if (sourceGuard) BlockchainResyncGuard.end(source);
|
||||
second.unlock();
|
||||
first.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyCurrentPda(KeyRotationSessionEntry session) throws Exception {
|
||||
SolanaUserPdaCurrentEntry user = users.getByLogin(session.getLogin());
|
||||
if (user == null) throw new IllegalStateException("current PDA not found");
|
||||
if (!session.getCandidateBlockchainName().equals(user.getBlockchainName())) {
|
||||
throw new IllegalStateException("current PDA blockchainName mismatch");
|
||||
}
|
||||
if (!session.getNewBlockchainKey().equals(user.getBlockchainKey())) {
|
||||
throw new IllegalStateException("current PDA blockchain key mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private void createCandidateState(KeyRotationSessionEntry session) throws Exception {
|
||||
SolanaUserPdaCurrentEntry user = users.getByLogin(session.getLogin());
|
||||
byte[] key = Base64.getDecoder().decode(session.getNewBlockchainKey());
|
||||
if (key.length != 32) throw new IllegalStateException("new blockchain key length != 32");
|
||||
|
||||
BlockchainStateEntry state = new BlockchainStateEntry();
|
||||
state.setBlockchainName(session.getCandidateBlockchainName());
|
||||
state.setLogin(session.getLogin());
|
||||
state.setBlockchainKey(session.getNewBlockchainKey());
|
||||
state.setSizeLimit(user != null && user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L);
|
||||
state.setFileSizeBytes(0L);
|
||||
state.setLastBlockNumber(-1);
|
||||
state.setLastBlockHash(null);
|
||||
state.setUpdatedAtMs(System.currentTimeMillis());
|
||||
states.insertIfMissing(state);
|
||||
}
|
||||
|
||||
private static String safeMessage(Throwable t) {
|
||||
String value = t == null ? "unknown" : String.valueOf(t.getMessage());
|
||||
if (value.length() > 1000) value = value.substring(0, 1000);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package server.ws;
|
||||
|
||||
import server.keyrotation.KeyRotationRebuildScheduler;
|
||||
|
||||
import org.eclipse.jetty.server.Server;
|
||||
import org.eclipse.jetty.servlet.ServletContextHandler;
|
||||
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
|
||||
@@ -61,6 +63,7 @@ public final class WsServer {
|
||||
// ANS-104: publish locally-created signed user blocks and discover blocks published by other servers.
|
||||
ArweaveBlockPublisherScheduler.startOrLog();
|
||||
ArweaveBlockSyncScheduler.startOrLog();
|
||||
KeyRotationRebuildScheduler.startOrLog();
|
||||
|
||||
// ============================================================
|
||||
// 2) Запуск Jetty WS
|
||||
|
||||
@@ -4,7 +4,8 @@ import blockchain.MsgSubType;
|
||||
import blockchain.body.ConnectionBody;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import blockchain.body.HeaderBody;
|
||||
import blockchain.body.TextBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import blockchain.body.TextReplyBody;
|
||||
import shine.db.DbController;
|
||||
import test.it.blockchain.AddBlockSender;
|
||||
import test.it.blockchain.ChainState;
|
||||
@@ -27,7 +28,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
* CONNECTION (type=3):
|
||||
* - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber)
|
||||
* - всегда имеет target:
|
||||
* toBlockchainName + toBlockGlobalNumber + toBlockHash32
|
||||
* toLogin + toBlockGlobalNumber + toBlockHash32
|
||||
*
|
||||
* Правило target для связей/подписок:
|
||||
* - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0)
|
||||
@@ -88,12 +89,12 @@ public class IT_03_AddBlock_NoAuth {
|
||||
// POST в канал "0"
|
||||
{
|
||||
var ln = st1.nextTextLineByRoot(root0);
|
||||
sender1.send(new TextBody(
|
||||
MsgSubType.TEXT_POST,
|
||||
sender1.send(new TextLineBody(
|
||||
root0,
|
||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||
"U1: story/post in channel 0",
|
||||
null, null, null
|
||||
MsgSubType.TEXT_POST,
|
||||
null, null, null,
|
||||
"U1: story/post in channel 0"
|
||||
), t);
|
||||
}
|
||||
|
||||
@@ -148,12 +149,12 @@ public class IT_03_AddBlock_NoAuth {
|
||||
byte[] newsPost0Hash;
|
||||
{
|
||||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||||
sender1.send(new TextBody(
|
||||
MsgSubType.TEXT_POST,
|
||||
sender1.send(new TextLineBody(
|
||||
newsRootBlock,
|
||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||
"U1: News post #0",
|
||||
null, null, null
|
||||
MsgSubType.TEXT_POST,
|
||||
null, null, null,
|
||||
"U1: News post #0"
|
||||
), t);
|
||||
|
||||
newsPost0Block = st1.lastBlockNumber();
|
||||
@@ -164,26 +165,26 @@ public class IT_03_AddBlock_NoAuth {
|
||||
// POST #1 в канал "News"
|
||||
{
|
||||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||||
sender1.send(new TextBody(
|
||||
MsgSubType.TEXT_POST,
|
||||
sender1.send(new TextLineBody(
|
||||
newsRootBlock,
|
||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||
"U1: News post #1",
|
||||
null, null, null
|
||||
MsgSubType.TEXT_POST,
|
||||
null, null, null,
|
||||
"U1: News post #1"
|
||||
), t);
|
||||
}
|
||||
|
||||
// EDIT_POST (в линии канала) -> target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName)
|
||||
// EDIT_POST -> target на ОРИГИНАЛЬНЫЙ POST по login + number + hash
|
||||
{
|
||||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||||
sender1.send(new TextBody(
|
||||
MsgSubType.TEXT_EDIT_POST,
|
||||
sender1.send(new TextLineBody(
|
||||
newsRootBlock,
|
||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||
"U1: News post #0 (EDIT)",
|
||||
null,
|
||||
MsgSubType.TEXT_EDIT_POST,
|
||||
newsPost0Block,
|
||||
newsPost0Hash
|
||||
newsPost0Hash,
|
||||
u1,
|
||||
"U1: News post #0 (EDIT)"
|
||||
), t);
|
||||
}
|
||||
|
||||
@@ -206,17 +207,17 @@ public class IT_03_AddBlock_NoAuth {
|
||||
|
||||
// 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2)
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW,
|
||||
bch2, u2HeaderBlock, u2HeaderHash,
|
||||
u2, u2HeaderBlock, u2HeaderHash,
|
||||
"U1 follows U2 (target=U2 HEADER)", t);
|
||||
|
||||
// 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1)
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
||||
bch1, newsRootBlock, newsRootHash,
|
||||
u1, newsRootBlock, newsRootHash,
|
||||
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
||||
|
||||
// 3) U2 подписался на второй канал U1 "Updates"
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
||||
bch1, updatesRootBlock, updatesRootHash,
|
||||
u1, updatesRootBlock, updatesRootHash,
|
||||
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
|
||||
|
||||
assertEquals(2, countConnectionsByOwner(u2, u1),
|
||||
@@ -230,30 +231,31 @@ public class IT_03_AddBlock_NoAuth {
|
||||
|
||||
// 4) FRIEND взаимно (на HEADER)
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||
bch2, u2HeaderBlock, u2HeaderHash,
|
||||
u2, u2HeaderBlock, u2HeaderHash,
|
||||
"U1 -> U2: FRIEND", t);
|
||||
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||
bch1, u1HeaderBlock, u1HeaderHash,
|
||||
u1, u1HeaderBlock, u1HeaderHash,
|
||||
"U2 -> U1: FRIEND", t);
|
||||
|
||||
// 5) CONTACT несколько
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
||||
bch2, u2HeaderBlock, u2HeaderHash,
|
||||
u2, u2HeaderBlock, u2HeaderHash,
|
||||
"U1 -> U2: CONTACT", t);
|
||||
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT,
|
||||
bch1, u1HeaderBlock, u1HeaderHash,
|
||||
u1, u1HeaderBlock, u1HeaderHash,
|
||||
"U2 -> U1: CONTACT", t);
|
||||
|
||||
// =========================
|
||||
// USER2 REPLY (ответ в чужой канал)
|
||||
// =========================
|
||||
{
|
||||
sender2.send(TextBody.newReply(
|
||||
bch1,
|
||||
sender2.send(new TextReplyBody(
|
||||
MsgSubType.TEXT_REPLY,
|
||||
newsPost0Block,
|
||||
newsPost0Hash,
|
||||
u1,
|
||||
"U2: reply to U1 News post #0 (cross-chain)"
|
||||
), t);
|
||||
}
|
||||
@@ -273,12 +275,12 @@ public class IT_03_AddBlock_NoAuth {
|
||||
|
||||
// U1 -> U3: CONTACT
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
||||
bch3, u3HeaderBlock, u3HeaderHash,
|
||||
u3, u3HeaderBlock, u3HeaderHash,
|
||||
"U1 -> U3: CONTACT", t);
|
||||
|
||||
// 6) U2 отписывается только от News
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW,
|
||||
bch1, newsRootBlock, newsRootHash,
|
||||
u1, newsRootBlock, newsRootHash,
|
||||
"U2 unfollows U1 channel 'News'", t);
|
||||
|
||||
assertEquals(1, countConnectionsByOwner(u2, u1),
|
||||
@@ -292,7 +294,7 @@ public class IT_03_AddBlock_NoAuth {
|
||||
|
||||
// 7) U1 убирает U2 из контактов (UNCONTACT)
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
|
||||
bch2, u2HeaderBlock, u2HeaderHash,
|
||||
u2, u2HeaderBlock, u2HeaderHash,
|
||||
"U1 -> U2: UNCONTACT", t);
|
||||
|
||||
r.ok("IT_03 сценарий блоков + connections выполнен");
|
||||
@@ -313,7 +315,7 @@ public class IT_03_AddBlock_NoAuth {
|
||||
private static void sendConnection(AddBlockSender sender,
|
||||
ChainState st,
|
||||
short subType,
|
||||
String toBlockchainName,
|
||||
String toLogin,
|
||||
int toBlockNumber,
|
||||
byte[] toBlockHash32,
|
||||
String logNote,
|
||||
@@ -321,7 +323,7 @@ public class IT_03_AddBlock_NoAuth {
|
||||
|
||||
if (TestConfig.DEBUG()) {
|
||||
TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF)
|
||||
+ " to=" + toBlockchainName
|
||||
+ " to=" + toLogin
|
||||
+ " targetBlock=" + toBlockNumber
|
||||
+ " note=" + logNote);
|
||||
}
|
||||
@@ -330,14 +332,14 @@ public class IT_03_AddBlock_NoAuth {
|
||||
|
||||
// КОНСТРУКТОР ИЗ ТВОЕГО КОДА:
|
||||
// ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber,
|
||||
// short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32)
|
||||
// short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32)
|
||||
sender.send(new ConnectionBody(
|
||||
0, // lineCode для connection линии
|
||||
ln.prevLineNumber,
|
||||
ln.prevLineHash32,
|
||||
ln.thisLineNumber,
|
||||
subType,
|
||||
toBlockchainName,
|
||||
toLogin,
|
||||
toBlockNumber,
|
||||
toBlockHash32
|
||||
), timeout);
|
||||
|
||||
@@ -151,7 +151,7 @@ public final class SeedDataPopulationHelper {
|
||||
line.prevLineHash32,
|
||||
line.thisLineNumber,
|
||||
relationSubType,
|
||||
bch(to),
|
||||
to,
|
||||
0,
|
||||
targetHeaderHash
|
||||
), timeout);
|
||||
|
||||
@@ -113,3 +113,13 @@ cp /path/to/SHiNE-product/application.properties ./application.properties
|
||||
После обновления `shine_users` серверный модуль `shine-server-solana-users-sync` должен обновляться вместе с программой: текущий codec принимает только PDA 1.2. Legacy PDA 1.0 не мигрируются; тестовые legacy-записи можно закрыть временной инструкцией `close_legacy_pda`.
|
||||
|
||||
Миграция PostgreSQL v25 добавляет `blockchain_forks_json`. Старые compatibility-колонки продолжают содержать активный (последний) fork, а полный список ключей fork сохраняется отдельно и используется для поиска владельца Arweave-записей по любому историческому blockchain key.
|
||||
|
||||
## PostgreSQL migration v26: key rotation runtime state
|
||||
|
||||
Migration v26 добавляет server-local поля `rotation_status` / `rotation_session_id` в `solana_user_pda_current` и таблицу `key_rotation_sessions`.
|
||||
|
||||
Это локальное состояние длительной смены ключей, а не часть Solana PDA. Solana users sync продолжает обновлять только PDA-поля и не должен затирать rotation-state. После обновления сервера миграция применяется стандартным `DatabaseInitializer` автоматически.
|
||||
|
||||
## Миграции key rotation
|
||||
|
||||
При обновлении сервера DatabaseInitializer последовательно применяет migration v26 (state machine смены ключей) и v27 (отдельные candidate-блоки будущего fork). Ручного создания таблиц не требуется. Candidate-блоки не входят в текущую таблицу `blocks` до финального переключения fork.
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
- `bad_block_number` — нарушена последовательность;
|
||||
- `bad_prev_hash` — нарушена SHiNE hash chain;
|
||||
- `bad_block_bytes` — DataItem/Frame не парсится.
|
||||
- `key_rotation_in_progress` (HTTP/status `423`) — для пользователя уже запущена смена ключей; обычный `AddBlock` временно запрещён до завершения/отмены ротации;
|
||||
- `key_rotation_check_failed` — сервер не смог проверить локальный rotation status и отклонил запись fail-closed.
|
||||
|
||||
## Storage/publish
|
||||
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
||||
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
||||
| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна |
|
||||
| `KeyRotationStart` | `19_Key_Rotation_API.md` | начать смену ключей сразу в состоянии `COPYING_CHAIN` |
|
||||
| `KeyRotationStatus` | `19_Key_Rotation_API.md` | получить текущий этап и прогресс смены ключей |
|
||||
| `KeyRotationAddBlock` | `19_Key_Rotation_API.md` | добавить подписанный новым blockchain key candidate-блок будущего fork |
|
||||
| `KeyRotationFinishChain` | `19_Key_Rotation_API.md` | проверить полную публикацию candidate-chain и перевести её в `CHAIN_READY` |
|
||||
| `KeyRotationRotatePda` | `19_Key_Rotation_API.md` | зафиксировать отправленную Solana-ротацию PDA и ждать подтверждения sync |
|
||||
| `KeyRotationContinue` | `19_Key_Rotation_API.md` | продолжить интерактивный post-PDA этап; сейчас wallet/DM отмечаются как `NOT_IMPLEMENTED` и пропускаются |
|
||||
| `KeyRotationAbort` | `19_Key_Rotation_API.md` | прервать ротацию до отправки Solana PDA-ротации |
|
||||
| `GetMyBlockchain` | `20_Get_My_Blockchain_API.md` | постранично читать текущую активную собственную цепочку для аудита и выбора fork point |
|
||||
| `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки |
|
||||
| `Ping` | `05_Technical_Requests_API.md` | keep-alive |
|
||||
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
|
||||
|
||||
@@ -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 не переключён.
|
||||
@@ -0,0 +1,46 @@
|
||||
# GetMyBlockchain API
|
||||
|
||||
`GetMyBlockchain` — авторизованное постраничное чтение **только текущей активной версии собственного блокчейна**. API используется постоянным экраном «Мой блокчейн» и мастером смены ключей для выбора последнего доверенного блока.
|
||||
|
||||
Login и активный `blockchainName` сервер определяет из авторизованной сессии/current PDA; клиент не может запросить этим методом чужую цепочку.
|
||||
|
||||
## Request
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetMyBlockchain",
|
||||
"requestId": "my-bch-1",
|
||||
"payload": {
|
||||
"beforeBlock": 500,
|
||||
"limit": 50,
|
||||
"includeBlockBytes": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Поля:
|
||||
|
||||
- `beforeBlock` — необязательный номер верхней границы страницы; если отсутствует, чтение начинается с current tip;
|
||||
- `limit` — `1..100`, default `50`;
|
||||
- `includeBlockBytes` — при `true` дополнительно вернуть полный ANS-104 DataItem в Base64.
|
||||
|
||||
## Response
|
||||
|
||||
Payload содержит:
|
||||
|
||||
- `login`;
|
||||
- `blockchainName`;
|
||||
- `tipBlockNumber` / `tipBlockHash`;
|
||||
- `nextBeforeBlock` для следующей страницы;
|
||||
- `blocks[]` в порядке от новых к старым.
|
||||
|
||||
Каждый элемент `blocks[]` содержит:
|
||||
|
||||
- `blockNumber`;
|
||||
- `blockHash` / `prevBlockHash`;
|
||||
- `timestampMs`;
|
||||
- `msgType` / `msgSubType` / `msgVersion`;
|
||||
- для target-блоков: `toLogin + toBlockNumber + toBlockHash`;
|
||||
- `blockBytesB64`, только если запрошен `includeBlockBytes=true`.
|
||||
|
||||
После fork API показывает только новую активную ветку PostgreSQL. Исторические fork при необходимости восстанавливаются из Arweave/PDA history отдельным будущим viewer-механизмом.
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## Быстрая карта типов
|
||||
|
||||
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
|
||||
- `type=0` — TECH: HEADER, CREATE_CHANNEL, FORK.
|
||||
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE.
|
||||
- `type=2` — REACTION: LIKE/UNLIKE.
|
||||
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
||||
|
||||
@@ -12,7 +12,43 @@ TECH-тип покрывает системные записи цепочки.
|
||||
- создание нового канала;
|
||||
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`.
|
||||
|
||||
3. `subType=2` — `TECH_FORK`
|
||||
- первый новый блок после точной перепубликации выбранного префикса предыдущего fork новым blockchain key;
|
||||
- связывает новую активную цепочку с предыдущей и фиксирует точку rollback/продолжения.
|
||||
|
||||
### `TECH_FORK` body (`version=1`)
|
||||
|
||||
Big-endian:
|
||||
|
||||
- `parentBlockchainKey[32]` — public key предыдущего fork;
|
||||
- `forkPointBlockNumber[4]` — последний блок старой цепочки, сохранённый в новом fork;
|
||||
- `forkPointBlockHash32[32]`;
|
||||
- `forkPointTimestampMs[8]`;
|
||||
- `parentTipBlockNumber[4]` — tip старой цепочки на момент начала ротации;
|
||||
- `parentTipBlockHash32[32]`;
|
||||
- `parentTipTimestampMs[8]`;
|
||||
- `discardedBlocksCount[4]` — `parentTipBlockNumber - forkPointBlockNumber`;
|
||||
- `reasonCode[1]`;
|
||||
- `commentUtf8Length[2]`;
|
||||
- `comment[N]` — произвольный комментарий пользователя, максимум 1024 UTF-8 байт.
|
||||
|
||||
`reasonCode`:
|
||||
|
||||
- `1` — `ROUTINE_ROTATION`: обычная смена пароля/ключей, компрометация не предполагается;
|
||||
- `2` — `POSSIBLE_COMPROMISE`: возможная компрометация, неизвестные записи не подтверждены;
|
||||
- `3` — `CONFIRMED_COMPROMISE_ROLLBACK`: обнаружены нежелательные/чужие записи и выполнен rollback;
|
||||
- `4` — `RECOVERY`: восстановление доступа recovery-механизмом.
|
||||
|
||||
Правила:
|
||||
|
||||
- блоки `0..forkPointBlockNumber` в новом fork должны быть точными Frame-копиями выбранного префикса предыдущей цепочки;
|
||||
- `TECH_FORK` идёт сразу после этого префикса и является первым действительно новым Frame нового fork;
|
||||
- если история сохранена полностью, `forkPointBlockNumber == parentTipBlockNumber` и `discardedBlocksCount == 0`;
|
||||
- если сохраняется только genesis, новый fork содержит прежний `block 0`, а `block 1` является `TECH_FORK`;
|
||||
- новый blockchain key в body не дублируется: он определяется owner/signature нового ANS-104 DataItem.
|
||||
|
||||
## Назначение
|
||||
|
||||
- инициализация блокчейна;
|
||||
- управление набором каналов пользователя.
|
||||
- управление набором каналов пользователя;
|
||||
- фиксация происхождения нового fork и причины ротации/rollback.
|
||||
|
||||
@@ -14,7 +14,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
||||
|
||||
3. `subType=20` — `TEXT_REPLY`
|
||||
- ответ на сообщение;
|
||||
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
|
||||
- target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
|
||||
|
||||
4. `subType=21` — `TEXT_EDIT_REPLY`
|
||||
- редактирование ответа;
|
||||
@@ -23,7 +23,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
||||
|
||||
5. `subType=30` — `TEXT_RATING`
|
||||
- target-based отзыв на конкретный блок;
|
||||
- содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
|
||||
- содержит target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
|
||||
- не является сообщением линии канала.
|
||||
|
||||
6. `subType=50` — `TEXT_REPOST`
|
||||
@@ -57,6 +57,20 @@ TEXT-тип хранит сообщения, материалы и редакт
|
||||
|
||||
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
||||
|
||||
|
||||
## Общий target-формат TEXT
|
||||
|
||||
Для `TEXT_EDIT_POST`, `TEXT_REPLY`, `TEXT_EDIT_REPLY`, `TEXT_RATING` и `TEXT_REPOST` ссылка на цель хранится как:
|
||||
|
||||
```text
|
||||
[1] toLoginLen (uint8)
|
||||
[N] toLogin UTF-8
|
||||
[4] toBlockGlobalNumber
|
||||
[32] toBlockHash32
|
||||
```
|
||||
|
||||
`blockchainName`/номер fork в подписываемые байты target не входит. Одинаковые `login + blockNumber + blockHash` считаются одной логической целью после перепубликации сохранённого префикса при fork.
|
||||
|
||||
## Правило для edit
|
||||
|
||||
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
||||
|
||||
@@ -4,11 +4,25 @@
|
||||
|
||||
1. `subType=1` — `REACTION_LIKE`
|
||||
- лайк на целевой блок;
|
||||
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||
- хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||
2. `subType=2` — `REACTION_UNLIKE`
|
||||
- снятие лайка с целевого блока;
|
||||
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||
- хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||
|
||||
## Назначение
|
||||
|
||||
- реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой).
|
||||
|
||||
|
||||
## Формат target
|
||||
|
||||
В подписанных байтах target больше не хранит имя fork/blockchain. Формат:
|
||||
|
||||
```text
|
||||
[1] toLoginLen (uint8)
|
||||
[N] toLogin UTF-8
|
||||
[4] toBlockGlobalNumber
|
||||
[32] toBlockHash32
|
||||
```
|
||||
|
||||
Логическая идентичность цели: `toLogin + toBlockGlobalNumber + toBlockHash32`. Поэтому ссылка остаётся той же после fork, если номер и hash исходного блока сохранены.
|
||||
|
||||
@@ -18,7 +18,18 @@ CONNECTION-тип описывает социальные связи и подп
|
||||
## Общий формат payload
|
||||
|
||||
- line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`)
|
||||
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`)
|
||||
- target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`)
|
||||
|
||||
## Бинарный target
|
||||
|
||||
```text
|
||||
[1] toLoginLen (uint8)
|
||||
[N] toLogin UTF-8
|
||||
[4] toBlockGlobalNumber
|
||||
[32] toBlockHash32
|
||||
```
|
||||
|
||||
Имя fork/blockchain в target не хранится.
|
||||
|
||||
## Правила target
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
Все `STATUS_ACTION` используют один и тот же бинарный body-формат:
|
||||
|
||||
```text
|
||||
[1] toBlockchainNameLen (uint8)
|
||||
[N] toBlockchainName UTF-8
|
||||
[1] toLoginLen (uint8)
|
||||
[N] toLogin UTF-8
|
||||
[4] toBlockGlobalNumber
|
||||
[32] toBlockHash32
|
||||
[2] textLenBytes (uint16)
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
Где:
|
||||
|
||||
- `toBlockchainName` — блокчейн, в котором находится целевой материал;
|
||||
- `toLogin` — login владельца целевого блока; номер fork в target не хранится;
|
||||
- `toBlockGlobalNumber` — номер целевого блока;
|
||||
- `toBlockHash32` — хэш целевого блока;
|
||||
- `text` — опциональное пояснение пользователя к статусу.
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
## 2026-09-27 — TECH_FORK v1
|
||||
|
||||
- Добавлен `type=0 / subType=2 / version=1` (`TECH_FORK`).
|
||||
- `TECH_FORK` фиксирует parent blockchain key, точку сохранённой истории, старый tip, число отброшенных блоков, reason code и пользовательский комментарий.
|
||||
- Новый blockchain key не дублируется в body: он определяется подписью нового ANS-104 DataItem.
|
||||
- Зафиксированы четыре причины: обычная ротация, возможная компрометация, подтверждённая компрометация с rollback и recovery.
|
||||
|
||||
# История изменений документации блокчейна
|
||||
|
||||
## 2026-09-23 — Turbo transport для individual ANS-104 DataItems
|
||||
|
||||
+19
-19
@@ -2,10 +2,10 @@
|
||||
|
||||
> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.**
|
||||
> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся
|
||||
> все ключи (root, blockchain, device/Solana, homeserver) — формулами, байт-в-байт.
|
||||
> все ключи (root, blockchain, client, homeserver) — формулами, байт-в-байт.
|
||||
> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула
|
||||
> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь
|
||||
> dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
|
||||
> blockchain key ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
|
||||
> Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура).
|
||||
> Здесь — только механика. Документ намеренно краткий.
|
||||
|
||||
@@ -55,29 +55,29 @@ seed(32) = SHA-256(material)
|
||||
| Ключ | Суффикс | Назначение (кратко) |
|
||||
|------|---------|---------------------|
|
||||
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
|
||||
| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). |
|
||||
| device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. |
|
||||
| blockchain | `blockchain.key` | Подписывает пользовательские блоки/ANS-104 DataItem и является текущим Solana-wallet/fee payer. |
|
||||
| client | `client.key` | Общий клиентский ключ для DM/устройств и derivation отдельного Arweave SAWD-кошелька; не является текущим Solana-wallet. |
|
||||
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
|
||||
|
||||
Полные роли каждого ключа — в `docs/Keys/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Solana-ключ
|
||||
## 3. Solana-кошелёк и authority
|
||||
|
||||
Отдельного «солана-ключа» нет. На Solana работают два ключа:
|
||||
Отдельного «solana.key» нет. В актуальной модели Solana-wallet пользователя — **активный `blockchain.key`**:
|
||||
|
||||
- **`client.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(clientPub)`.
|
||||
Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`.
|
||||
Пополнять SOL нужно именно на этот адрес.
|
||||
- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer.
|
||||
- адрес кошелька = `base58(activeBlockchainPub)`;
|
||||
- им оплачивается регистрация (`create_user_pda`) и обычные `update_user_pda`;
|
||||
- обычное обновление PDA авторизуется активным blockchain key;
|
||||
- новый fork подписывает новое PDA новым blockchain key, а старый активный blockchain key разрешает обычный переход;
|
||||
- полная смена пароля/ключей — особый recovery-переход: старый `root.key` дополнительно разрешает одно новое unsigned PDA state, а **новый blockchain key** подписывает тот же hash и становится новым wallet/authority.
|
||||
|
||||
Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1
|
||||
(«create/update оплачиваются с `client_key`», «root_key — не fee payer»).
|
||||
`root.key` не является fee payer. Он используется как холодное дополнительное разрешение только для полной ротации root + client + blockchain fork.
|
||||
|
||||
Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью
|
||||
(`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый
|
||||
кошелёк и плательщик комиссий**. Полное описание ролей — `docs/Keys/README.md`.
|
||||
`client.key` больше не используется как Solana-wallet/fee payer. Он остаётся клиентским криптографическим ключом (DM/устройства) и входом в отдельный протокол derivation Arweave-кошелька.
|
||||
|
||||
При fork Solana-адрес меняется вместе с blockchain key. Перенос SOL со старого blockchain-wallet на новый является отдельным необязательным этапом ротации; в текущей первой реализации этот этап оставлен явной заглушкой.
|
||||
|
||||
---
|
||||
|
||||
@@ -117,9 +117,9 @@ homeserver.key:home-b -> ключ B
|
||||
- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160).
|
||||
|
||||
### Solana-ключ / адрес кошелька (UI)
|
||||
- `shine-UI/js/pages/registration-payment-view.js` — `deriveUserWalletAddress`: адрес = `base58(clientPub)` (~113).
|
||||
- `shine-UI/js/pages/topup-view.js` — `clientWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.clientPair`.
|
||||
Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `client.key`, мимо `masterSecret`) удалён.
|
||||
- `shine-UI/js/pages/registration-payment-view.js` — адрес пополнения = `base58(blockchainPub)`.
|
||||
- `shine-UI/js/pages/topup-view.js` — тот же адрес из `preGeneratedKeyBundle.blockchainPair`.
|
||||
- `shine-UI/js/services/solana-wallet-service.js` — текущий пользовательский Solana-wallet загружается из сохранённого `blockchainKey`.
|
||||
|
||||
### Деривация ключей (прошивка ESP32)
|
||||
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
|
||||
@@ -147,7 +147,7 @@ homeserver.key:home-b -> ключ B
|
||||
|
||||
1. Этот документ — источник истины по деривации секрета и ключей.
|
||||
2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа,
|
||||
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь dev-ключ ↔ Solana-адрес —
|
||||
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь blockchain key ↔ Solana-адрес —
|
||||
**обязательно** отражать здесь в том же изменении.
|
||||
3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма.
|
||||
4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости.
|
||||
|
||||
+11
-14
@@ -8,9 +8,9 @@
|
||||
|
||||
В SHiNE у пользователя есть несколько уровней ключей:
|
||||
|
||||
- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`).
|
||||
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
|
||||
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
|
||||
- `root key` - холодный recovery-ключ: при полной ротации дополнительно разрешает замену root + client + blockchain fork. Это не кошелёк.
|
||||
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн и текущий Solana-wallet/fee payer пользователя.
|
||||
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков и DM; Solana-wallet им больше не является.
|
||||
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
|
||||
|
||||
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
|
||||
@@ -21,16 +21,13 @@
|
||||
|
||||
Назначение:
|
||||
|
||||
- регистрация пользователя в Solana;
|
||||
- создание и обновление пользовательской PDA-записи;
|
||||
- вызов критически важных Solana-функций;
|
||||
- изменение главных настроек пользователя;
|
||||
- управление остальными ключами;
|
||||
- подтверждение операций, которые должны иметь максимальный уровень доверия.
|
||||
- холодное recovery-разрешение для полной смены ключей;
|
||||
- подтверждение атомарной ротации `root + client + новый blockchain fork`;
|
||||
- восстановительные сценарии повышенного уровня доверия.
|
||||
|
||||
`root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя.
|
||||
Обычные PDA-update **не требуют root key**: их выполняет активный blockchain key. При полной ротации старый root подписывает тот же hash нового unsigned PDA state, который подписывает новый blockchain key. Так root разрешает переход, не становясь повседневным ключом.
|
||||
|
||||
Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `client key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `docs/Keys/DERIVATION.md`, §3.
|
||||
Важно не путать recovery-authority и кошелёк: `root key` не является fee payer. Текущий Solana-wallet/fee payer — активный `blockchain key`. Подробнее — `docs/Keys/DERIVATION.md`, §3.
|
||||
|
||||
## `blockchain key`
|
||||
|
||||
@@ -40,7 +37,8 @@
|
||||
|
||||
- подпись записей в персональном блокчейне пользователя;
|
||||
- подтверждение действий, которые должны попасть в SHiNE-блокчейн;
|
||||
- разделение полномочий между главным Solana-ключом и ключом ежедневной записи.
|
||||
- обычные обновления пользовательской PDA;
|
||||
- текущий Solana-wallet/fee payer (`base58(active blockchain public key)`).
|
||||
|
||||
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
|
||||
|
||||
@@ -59,7 +57,6 @@
|
||||
- повседневные входящие и исходящие личные сообщения;
|
||||
- звонки и связанные с ними сообщения;
|
||||
- self-messages, то есть внутренние сообщения пользователя самому себе;
|
||||
- мелкие Solana-расходы на текущие операции;
|
||||
- derivation Arweave-кошелька;
|
||||
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
|
||||
|
||||
@@ -158,7 +155,7 @@ Self-message - это сообщение пользователя самому
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код).
|
||||
- `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`blockchain.key`/`client.key`/`homeserver.key:<имя>`, Solana-wallet, ссылки на код).
|
||||
- `docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений.
|
||||
- `docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений.
|
||||
- `docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
|
||||
|
||||
@@ -584,3 +584,28 @@ SYNC_POLL_INTERVAL_SECONDS=300
|
||||
- server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес.
|
||||
|
||||
Create/update транзакции больше не реконструируются байт-в-байт из instruction args. После обнаружения изменения sync-модуль перечитывает фактическую текущую PDA через Solana RPC и декодирует её. Это исключает дублирование on-chain сериализации. `close_legacy_pda` не создаёт новое состояние PDA и для runtime-sync не является пользовательским update.
|
||||
|
||||
---
|
||||
|
||||
## Server-local key rotation state (PostgreSQL v26)
|
||||
|
||||
Начиная с migration v26 сервер хранит локальное состояние длительной смены ключей отдельно от данных Solana PDA.
|
||||
|
||||
В `solana_user_pda_current` добавлены локальные поля:
|
||||
|
||||
- `rotation_status` — быстрый текущий статус (`NONE` в обычном режиме);
|
||||
- `rotation_session_id` — ссылка на текущую запись `key_rotation_sessions`.
|
||||
|
||||
Эти поля **не являются частью PDA**, не приходят из Solana и не должны перезаписываться обычным Solana sync upsert-ом.
|
||||
|
||||
Подробный прогресс хранится в `key_rotation_sessions`: только публичные old/new root/blockchain/client keys, выбранная точка fork, старый tip, reason/comment, прогресс, ошибка/retry и статусы необязательных wallet/DM этапов. Пароли и приватные ключи в PostgreSQL не сохраняются.
|
||||
|
||||
Первая серверная запись создаётся сразу в `COPYING_CHAIN`; состояния `PREPARING` в БД нет. Завершённые `COMPLETE`/`ABORTED` sessions остаются как журнал, а `solana_user_pda_current.rotation_status` возвращается в `NONE`.
|
||||
|
||||
### Candidate blocks ротации (PostgreSQL v27)
|
||||
|
||||
Начиная с migration v27 будущая ветка во время `COPYING_CHAIN` хранится в отдельной таблице `key_rotation_candidate_blocks`. Она не является частью текущего materialized blockchain state и не должна попадать в обычную `blocks` до финального переключения fork.
|
||||
|
||||
Для каждого candidate DataItem сохраняются rotation session, login, candidate blockchain name, block number/hash, полный ANS-104 DataItem, DataItem id и статус публикации. Уникальность `(rotation_session_id, block_number)` запрещает две разные версии одного candidate-блока.
|
||||
|
||||
Arweave/Turbo publisher обрабатывает candidate-блоки приоритетно. `key_rotation_sessions.progress_current` отражает число DataItem, уже реально опубликованных publisher-ом, а не число принятых API-сервером.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
Основные данные:
|
||||
|
||||
- `RootKeyBlock` — cold recovery authority;
|
||||
- `ClientKeyBlock` — клиентский/кошелёчный ключ;
|
||||
- `ClientKeyBlock` — клиентский ключ для DM/устройств; Solana-wallet/fee payer — активный blockchain key;
|
||||
- `BlockchainRegistryBlock` — append-only список fork: `blockchain_key[32] + created_at_ms:u64 + paid_limit_bytes:u32`; последний fork активен;
|
||||
- необязательный `ServerProfileBlock` — в 1.2 ровно один адрес сервера;
|
||||
- необязательный `AccessServersBlock` — в 1.2 максимум один access server.
|
||||
|
||||
@@ -90,14 +90,14 @@
|
||||
|
||||
## Кто оплачивает create/update user_pda
|
||||
|
||||
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`.
|
||||
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя или сервера.
|
||||
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
|
||||
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`.
|
||||
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с активного `blockchainKey`.
|
||||
- В UI это означает, что Solana fee payer всегда берётся из текущего активного `blockchain key` пользователя или сервера.
|
||||
- `rootKey` не оплачивает транзакцию; он дополнительно авторизует только полную ротацию ключей.
|
||||
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес текущего `blockchainKey`.
|
||||
|
||||
## Важно
|
||||
|
||||
- `init_users_economy_config` выполняется один раз на программу. Если PDA уже создан, повторный вызов вернёт ошибку `already initialized`.
|
||||
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: транзакцию оплачивает `clientKey`, а содержимое записи подписывает `rootKey`.
|
||||
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: транзакцию оплачивает `blockchainKey`. Обычное состояние PDA подписывает blockchain key; root дополнительно участвует только в полной ротации ключей.
|
||||
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
|
||||
- При новом devnet deploy планируется использовать те же program keypair, чтобы `program id` на devnet совпадали с mainnet.
|
||||
|
||||
@@ -16,6 +16,7 @@ import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'
|
||||
import { initPwaPush } from './services/pwa-push-service.js';
|
||||
import { initCallUiOverlay } from './services/call-ui-service.js';
|
||||
import { showToast } from './services/channels-ux.js';
|
||||
import { KeyRotationClient } from './services/key-rotation-service.js';
|
||||
import {
|
||||
handleCallPushAction,
|
||||
handleIncomingCallInvite,
|
||||
@@ -73,6 +74,8 @@ import * as profileEditView from './pages/profile-edit-view.js';
|
||||
import * as profilesView from './pages/profiles-view.js';
|
||||
import * as walletView from './pages/wallet-view.js?v=202609260900';
|
||||
import * as settingsView from './pages/settings-view.js';
|
||||
import * as myBlockchainView from './pages/my-blockchain-view.js';
|
||||
import * as keyRotationView from './pages/key-rotation-view.js';
|
||||
import * as accessServersView from './pages/access-servers-view.js';
|
||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||
import * as advancedSettingsView from './pages/advanced-settings-view.js';
|
||||
@@ -142,6 +145,8 @@ const routes = {
|
||||
'profiles-view': profilesView,
|
||||
'wallet-view': walletView,
|
||||
'settings-view': settingsView,
|
||||
'my-blockchain-view': myBlockchainView,
|
||||
'key-rotation-view': keyRotationView,
|
||||
'access-servers-view': accessServersView,
|
||||
'developer-settings-view': developerSettingsView,
|
||||
'advanced-settings-view': advancedSettingsView,
|
||||
@@ -1376,6 +1381,21 @@ function attachPageScrollToBottom(pageId, screen) {
|
||||
});
|
||||
}
|
||||
|
||||
async function redirectToActiveKeyRotationIfNeeded() {
|
||||
if (!state.session.isAuthorized || state.session.isLocalDemo) return false;
|
||||
try {
|
||||
const rotation = await new KeyRotationClient(authService).status();
|
||||
const status = String(rotation?.rotationStatus || 'NONE');
|
||||
if (status !== 'NONE' && getRoute().pageId !== 'key-rotation-view') {
|
||||
navigate('key-rotation-view');
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[key-rotation] status check failed', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||
const route = getRoute();
|
||||
@@ -1566,6 +1586,7 @@ async function init() {
|
||||
setSessionAuthorizedHandler(() => {
|
||||
void ensureSessionRuntimeStarted();
|
||||
void processPendingCallPushActionIfPossible();
|
||||
void redirectToActiveKeyRotationIfNeeded();
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
|
||||
@@ -290,7 +290,7 @@ export function openArweaveAttachmentManager({
|
||||
mode = 'attachment',
|
||||
historyPurpose = '',
|
||||
uploadTransport = 'turbo',
|
||||
turboKeySource = 'client',
|
||||
turboKeySource = 'blockchain',
|
||||
dialogTitle = '',
|
||||
uploadButtonLabel = '',
|
||||
initialFile = null,
|
||||
@@ -406,20 +406,9 @@ export function openArweaveAttachmentManager({
|
||||
turboKeyChoices = await getStoredSolanaWalletChoices({
|
||||
login: cleanLogin,
|
||||
storagePwd: cleanStoragePwd,
|
||||
includeRoot: true,
|
||||
});
|
||||
turboKeyChoices.sort((a, b) => {
|
||||
if (a?.keySource === b?.keySource) return 0;
|
||||
if (a?.keySource === 'client') return -1;
|
||||
if (b?.keySource === 'client') return 1;
|
||||
return 0;
|
||||
});
|
||||
if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) {
|
||||
selectedTurboKeySource = String(
|
||||
turboKeyChoices.find((item) => item.keySource === 'client')?.keySource
|
||||
|| turboKeyChoices[0]?.keySource
|
||||
|| 'client'
|
||||
);
|
||||
selectedTurboKeySource = String(turboKeyChoices[0]?.keySource || 'blockchain');
|
||||
}
|
||||
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
|
||||
} catch (error) {
|
||||
|
||||
@@ -177,14 +177,14 @@ function createPasswordModal() {
|
||||
<div class="stack" style="gap:0.45rem;">
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="once" checked />
|
||||
<span>Использовать главный ключ только сейчас</span>
|
||||
<span>Использовать blockchain key только сейчас</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="save" />
|
||||
<span>Сохранить главный ключ на этом устройстве</span>
|
||||
<span>Сохранить blockchain key на этом устройстве</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
|
||||
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, blockchain key попадёт в зашифрованный контейнер устройства.</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button>
|
||||
<button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button>
|
||||
@@ -247,7 +247,7 @@ function createPasswordModal() {
|
||||
const mode = root.querySelector('input[name="access-servers-key-mode"]:checked');
|
||||
close({
|
||||
password,
|
||||
saveRoot: mode instanceof HTMLInputElement && mode.value === 'save',
|
||||
saveBlockchain: mode instanceof HTMLInputElement && mode.value === 'save',
|
||||
});
|
||||
});
|
||||
window.setTimeout(() => inputEl.focus(), 0);
|
||||
@@ -482,48 +482,49 @@ export function render({navigate, chrome}) {
|
||||
saved = null;
|
||||
}
|
||||
|
||||
const savedRoot = String(saved?.rootKey || '').trim();
|
||||
const savedBlockchain = String(saved?.blockchainKey || '').trim();
|
||||
const savedClient = String(saved?.clientKey || '').trim();
|
||||
if (savedRoot && savedClient) {
|
||||
if (savedBlockchain && savedClient) {
|
||||
return {
|
||||
rootPrivatePkcs8B64: savedRoot,
|
||||
blockchainPrivatePkcs8B64: savedBlockchain,
|
||||
clientPrivatePkcs8B64: savedClient,
|
||||
clientAddress: await clientAddressFromPrivatePkcs8(savedClient),
|
||||
payerAddress: await clientAddressFromPrivatePkcs8(savedBlockchain),
|
||||
};
|
||||
}
|
||||
|
||||
const passwordResult = await passwordModal?.open({
|
||||
title: 'Нужен пароль для обновления серверов доступа',
|
||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
note: savedClient
|
||||
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.'
|
||||
: 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
|
||||
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен blockchain key.'
|
||||
: 'На устройстве не хватает blockchain key и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
|
||||
});
|
||||
if (!passwordResult) {
|
||||
throw new Error('Операция отменена пользователем.');
|
||||
}
|
||||
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password);
|
||||
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64);
|
||||
const derivedBlockchainPublic = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
|
||||
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
|
||||
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
|
||||
throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.');
|
||||
const activeBlockchain = currentPda.forks?.at(-1)?.blockchainKey;
|
||||
if (!activeBlockchain || !equalBytes(derivedBlockchainPublic, activeBlockchain)) {
|
||||
throw new Error('Пароль не подходит: blockchain key не совпал с активным fork аккаунта.');
|
||||
}
|
||||
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
|
||||
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
|
||||
}
|
||||
|
||||
if (passwordResult.saveRoot) {
|
||||
if (passwordResult.saveBlockchain) {
|
||||
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
|
||||
saveRoot: true,
|
||||
saveBlockchain: false,
|
||||
saveRoot: false,
|
||||
saveBlockchain: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
|
||||
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
|
||||
clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64),
|
||||
payerAddress: clientAddressFromPublicB64(keyBundle.blockchainPair.publicKeyB64),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -550,7 +551,7 @@ export function render({navigate, chrome}) {
|
||||
const tx = await updateShineUserPdaOnSolana({
|
||||
login: sessionLogin,
|
||||
solanaEndpoint,
|
||||
rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64: signingMaterial.blockchainPrivatePkcs8B64,
|
||||
clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64,
|
||||
accessServers: normalizedList,
|
||||
});
|
||||
@@ -562,7 +563,7 @@ export function render({navigate, chrome}) {
|
||||
refreshAddButton();
|
||||
} catch (error) {
|
||||
if (isInsufficientFundsForRentError(error)) {
|
||||
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
|
||||
showTopupRequiredStatus(target, signingMaterial?.payerAddress);
|
||||
} else {
|
||||
target.textContent = error?.message || 'Не удалось обновить сервер доступа.';
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
formatSol,
|
||||
getBalanceSol,
|
||||
getSolanaWalletFromStoredSecret,
|
||||
getWalletFromStoredClientKey,
|
||||
getWalletFromStoredBlockchainKey,
|
||||
getWalletFromStoredRootKey,
|
||||
solanaAddressFromPublicKeyBase64,
|
||||
transferSol,
|
||||
@@ -138,9 +138,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
||||
if (!root) return;
|
||||
|
||||
const recipientChoices = [
|
||||
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
|
||||
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
|
||||
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
|
||||
].filter(Boolean);
|
||||
|
||||
root.innerHTML = `
|
||||
@@ -166,10 +164,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
||||
<div class="channel-support-address" id="channel-support-recipient-address">—</div>
|
||||
|
||||
<label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label>
|
||||
<select class="select" id="channel-support-sender-key">
|
||||
<option value="client-key">Client key</option>
|
||||
<option value="root-key">Root key</option>
|
||||
</select>
|
||||
<div class="channel-support-address" id="channel-support-sender-key">Blockchain key</div>
|
||||
<div class="channel-support-address" id="channel-support-sender-address">—</div>
|
||||
|
||||
<div class="channel-support-balance-row">
|
||||
@@ -247,31 +242,13 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
||||
};
|
||||
|
||||
const resolveSenderWallet = async () => {
|
||||
const keyId = String(senderSelect?.value || 'client-key');
|
||||
const keyId = 'blockchain-key';
|
||||
if (walletCache.has(keyId)) return walletCache.get(keyId);
|
||||
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
||||
|
||||
let wallet;
|
||||
if (keyId === 'root-key') {
|
||||
try {
|
||||
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
|
||||
} catch {
|
||||
const password = window.prompt(
|
||||
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
|
||||
'',
|
||||
);
|
||||
if (password == null) throw new Error('Операция отменена.');
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
||||
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
|
||||
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
|
||||
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
|
||||
}
|
||||
} else {
|
||||
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
|
||||
}
|
||||
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
|
||||
|
||||
walletCache.set(keyId, wallet);
|
||||
return wallet;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
formatSol,
|
||||
getBalanceSol,
|
||||
getSolanaWalletFromStoredSecret,
|
||||
getWalletFromStoredClientKey,
|
||||
getWalletFromStoredBlockchainKey,
|
||||
getWalletFromStoredRootKey,
|
||||
solanaAddressFromPublicKeyBase64,
|
||||
transferSol,
|
||||
@@ -140,9 +140,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
|
||||
const channelName = String(channel?.channelName || selector?.channelName || '').trim();
|
||||
const recipientChoices = [
|
||||
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
|
||||
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
|
||||
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
|
||||
].filter(Boolean);
|
||||
|
||||
content.innerHTML = `
|
||||
@@ -169,10 +167,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
<section class="channel-donate-section">
|
||||
<label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label>
|
||||
<select class="select" id="channel-donate-sender-key">
|
||||
<option value="client-key">Client key</option>
|
||||
<option value="root-key">Root key</option>
|
||||
</select>
|
||||
<div class="channel-donate-address" id="channel-donate-sender-key">Blockchain key</div>
|
||||
<div class="channel-donate-address" id="channel-donate-sender-address">—</div>
|
||||
|
||||
<div class="channel-donate-balance-row">
|
||||
@@ -233,31 +228,13 @@ export function render({ navigate, route, chrome }) {
|
||||
};
|
||||
|
||||
const resolveSenderWallet = async () => {
|
||||
const keyId = String(senderSelect?.value || 'client-key');
|
||||
const keyId = 'blockchain-key';
|
||||
if (walletCache.has(keyId)) return walletCache.get(keyId);
|
||||
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
||||
|
||||
let wallet;
|
||||
if (keyId === 'root-key') {
|
||||
try {
|
||||
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
|
||||
} catch {
|
||||
const password = window.prompt(
|
||||
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
|
||||
'',
|
||||
);
|
||||
if (password == null) throw new Error('Операция отменена.');
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
||||
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
|
||||
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
|
||||
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
|
||||
}
|
||||
} else {
|
||||
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
|
||||
}
|
||||
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
|
||||
|
||||
walletCache.set(keyId, wallet);
|
||||
return wallet;
|
||||
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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;
|
||||
}
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
@@ -126,7 +126,7 @@ export function render({ navigate }) {
|
||||
// Blockchain key
|
||||
const bchSep = document.createElement('p');
|
||||
bchSep.className = 'field-label';
|
||||
bchSep.textContent = 'Blockchain key';
|
||||
bchSep.textContent = 'Blockchain key (= Solana wallet)';
|
||||
card.append(bchSep);
|
||||
card.append(makePublicField({
|
||||
label: 'Blockchain — публичный (base58)',
|
||||
@@ -140,7 +140,7 @@ export function render({ navigate }) {
|
||||
// Client key
|
||||
const devSep = document.createElement('p');
|
||||
devSep.className = 'field-label';
|
||||
devSep.textContent = 'Client key (= Solana wallet)';
|
||||
devSep.textContent = 'Client key';
|
||||
card.append(devSep);
|
||||
card.append(makePublicField({
|
||||
label: 'Client — публичный (base58)',
|
||||
|
||||
@@ -234,7 +234,7 @@ export function render({ navigate }) {
|
||||
const deriveUserWalletAddress = async () => {
|
||||
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
|
||||
if (!keyBundle) throw new Error('Ключи ещё не сгенерированы. Вернитесь на предыдущий шаг.');
|
||||
const { publicKeyB64 } = keyBundle.clientPair;
|
||||
const { publicKeyB64 } = keyBundle.blockchainPair;
|
||||
const raw = atob(publicKeyB64);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
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 });
|
||||
} catch (error) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = `Не удалось подготовить client.key: ${error?.message || 'unknown'}`;
|
||||
status.textContent = `Не удалось подготовить blockchain key: ${error?.message || 'unknown'}`;
|
||||
status.style.display = '';
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -73,6 +73,7 @@ export function render({navigate, chrome}) {
|
||||
</div>
|
||||
<div class="nav-list">
|
||||
${row('settings-device', 'Устройства', 'Сеансы, подключение устройств, ключи')}
|
||||
${row('settings-my-blockchain', 'Мой блокчейн', 'Все ваши подписанные события и смена ключей')}
|
||||
${row('settings-access-servers', 'Сервер доступа', 'Личная переписка, звонки и зашифрованные данные')}
|
||||
${row('settings-blockchain-servers', 'Серверы блокчейнов', 'Solana, Сияние и Arweave для публичных данных')}
|
||||
${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-my-blockchain').addEventListener('click', () => navigate('my-blockchain-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-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||
|
||||
@@ -14,15 +14,14 @@ const DEVNET_ENDPOINT = 'https://api.devnet.solana.com';
|
||||
const SENDER_PRIVATE_32_BASE58 = '6xqAuKYvA8qrCdAkcw7Y8aMgvBnYk8JLxWLma5BzbAvu';
|
||||
const REGISTRATION_TOPUP_AMOUNT_SOL = 0.02;
|
||||
|
||||
// Канонический Solana-адрес пополнения = публичный device-ключ из сгенерированного набора ключей.
|
||||
// Тот же путь, что в registration-payment-view (deriveUserWalletAddress); не выводим адрес
|
||||
// напрямую из пароля, иначе он расходится с device-ключом регистрации.
|
||||
async function clientWalletAddressFromBundle() {
|
||||
// Канонический Solana-кошелёк пользователя = текущий blockchain key.
|
||||
// Тот же адрес используется как fee payer для регистрации и обычных PDA update.
|
||||
async function blockchainWalletAddressFromBundle() {
|
||||
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
|
||||
if (!keyBundle || !keyBundle.clientPair) {
|
||||
if (!keyBundle || !keyBundle.blockchainPair) {
|
||||
throw new Error('Ключи ещё не сгенерированы. Вернитесь на экран регистрации.');
|
||||
}
|
||||
const raw = atob(keyBundle.clientPair.publicKeyB64);
|
||||
const raw = atob(keyBundle.blockchainPair.publicKeyB64);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
|
||||
const { PublicKey } = await loadSolanaWeb3();
|
||||
@@ -73,7 +72,7 @@ export function render({ navigate }) {
|
||||
card.innerHTML = `
|
||||
<p class="auth-copy">Можете или пополнить счёт тестовыми соланами и продолжить регистрацию.</p>
|
||||
<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 class="stack" style="gap:6px;">
|
||||
<p class="meta-muted">Или можете отдельно открыть страницу тестового пополнения.</p>
|
||||
@@ -139,7 +138,7 @@ export function render({ navigate }) {
|
||||
(async () => {
|
||||
try {
|
||||
if (!walletValue.value) {
|
||||
const address = await clientWalletAddressFromBundle();
|
||||
const address = await blockchainWalletAddressFromBundle();
|
||||
state.registrationPayment.walletAddress = address;
|
||||
walletValue.value = address;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
formatSol,
|
||||
getBalanceSol,
|
||||
getTopupSiteUrl,
|
||||
getWalletFromStoredClientKey,
|
||||
getWalletFromStoredBlockchainKey,
|
||||
transferSol,
|
||||
} from '../services/solana-wallet-service.js';
|
||||
import {
|
||||
@@ -983,7 +983,7 @@ export function render({navigate, chrome}) {
|
||||
<h2 style="margin:0;">Купить билет на сумму в долларах</h2>
|
||||
<p class="meta-muted" style="margin:0; line-height:1.55;">
|
||||
Здесь вводится сумма покупки и адрес получателя. Если адрес не указан, можно купить на тот же кошелёк, с которого идет оплата.
|
||||
Покупка подписывается вашим client key и отклоняется, если курс уходит дальше допустимого порога.
|
||||
Оплата выполняется с текущего blockchain key. Технические подписи протокола покупки остаются отдельной частью операции.
|
||||
</p>
|
||||
`;
|
||||
|
||||
@@ -1169,7 +1169,7 @@ export function render({navigate, chrome}) {
|
||||
currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer);
|
||||
}
|
||||
if (!walletCtx) {
|
||||
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
|
||||
walletCtx = await getWalletFromStoredBlockchainKey(sessionArgsOrThrow());
|
||||
walletAddress = walletCtx.address;
|
||||
}
|
||||
setRecipientFromWallet();
|
||||
@@ -1265,7 +1265,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
if (!walletCtx) {
|
||||
try {
|
||||
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
|
||||
walletCtx = await getWalletFromStoredBlockchainKey(sessionArgsOrThrow());
|
||||
walletAddress = walletCtx.address;
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setRecipientFromWallet();
|
||||
@@ -1867,7 +1867,7 @@ export function render({navigate, chrome}) {
|
||||
addressCard.className = 'card';
|
||||
addressCard.style.padding = '10px';
|
||||
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>
|
||||
`;
|
||||
const addressEl = addressCard.querySelector('#wallet-address-value');
|
||||
@@ -1932,7 +1932,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
if (!walletCtx?.keypair) {
|
||||
setStatus('Перевод недоступен: client.key не загружен.');
|
||||
setStatus('Перевод недоступен: blockchain.key не загружен.');
|
||||
return;
|
||||
}
|
||||
const toAddress = window.prompt('Введите адрес получателя (Solana):', '');
|
||||
@@ -1968,17 +1968,17 @@ export function render({navigate, chrome}) {
|
||||
});
|
||||
|
||||
content.append(backBtn, card, actions);
|
||||
setStatus('Инициализация client.key...');
|
||||
setStatus('Инициализация blockchain.key...');
|
||||
|
||||
try {
|
||||
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
|
||||
walletCtx = await getWalletFromStoredBlockchainKey(sessionArgsOrThrow());
|
||||
if (modeToken !== activeModeToken) return;
|
||||
walletAddress = walletCtx.address;
|
||||
addressEl.textContent = walletAddress;
|
||||
await refreshBalance();
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
addressEl.textContent = 'client.key недоступен';
|
||||
addressEl.textContent = 'blockchain.key недоступен';
|
||||
setStatus(`Не удалось инициализировать кошелёк: ${error?.message || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
@@ -2146,7 +2146,7 @@ export function render({navigate, chrome}) {
|
||||
await refreshBalance();
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
addressEl.textContent = 'client.key недоступен';
|
||||
addressEl.textContent = 'blockchain.key недоступен';
|
||||
clearArweaveSecretsInMemory();
|
||||
setStatus(`Не удалось инициализировать Arweave-кошелёк: ${error?.message || 'unknown'}`);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ const PRETTY_PATHS = new Map([
|
||||
['network-view', 'network'],
|
||||
['notifications-view', 'notifications'],
|
||||
['settings-view', 'settings'],
|
||||
['my-blockchain-view', 'settings/blockchain'],
|
||||
['key-rotation-view', 'settings/key-rotation'],
|
||||
['access-servers-view', 'settings/access-servers'],
|
||||
['server-settings-view', 'settings/servers'],
|
||||
['arweave-uploads-view', 'settings/arweave-uploads'],
|
||||
@@ -324,6 +326,8 @@ export function parseRouteFromPath(pathname = '') {
|
||||
if (pageId === 'settings') {
|
||||
const sub = decodePart(segments[1] || '').toLowerCase();
|
||||
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 === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} };
|
||||
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
|
||||
@@ -499,6 +503,8 @@ function resolveToolbarTab(pageId) {
|
||||
pageId === 'profiles-view' ||
|
||||
pageId === 'wallet-view' ||
|
||||
pageId === 'settings-view' ||
|
||||
pageId === 'my-blockchain-view' ||
|
||||
pageId === 'key-rotation-view' ||
|
||||
pageId === 'access-servers-view' ||
|
||||
pageId === 'developer-settings-view' ||
|
||||
pageId === 'advanced-settings-view' ||
|
||||
|
||||
@@ -123,3 +123,81 @@ export async function createAns104DataItem({ owner32, privateKey, data, tags = [
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -553,31 +553,31 @@ function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thi
|
||||
);
|
||||
}
|
||||
|
||||
function makeReactionLikeBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex }) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for like');
|
||||
function makeReactionLikeBodyBytes({ toLogin, toBlockNumber, toBlockHashHex }) {
|
||||
const cleanLogin = String(toLogin || '').trim();
|
||||
if (!cleanLogin) throw new Error('toLogin is required for like');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for like');
|
||||
}
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
const loginBytes = utf8Bytes(cleanLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) {
|
||||
throw new Error('toLogin must be 1..255 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex))
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text }) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for reply');
|
||||
function makeTextReplyBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text }) {
|
||||
const cleanLogin = String(toLogin || '').trim();
|
||||
if (!cleanLogin) throw new Error('toLogin is required for reply');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
@@ -587,9 +587,9 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Reply text is required');
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
const loginBytes = utf8Bytes(cleanLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) {
|
||||
throw new Error('toLogin must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const textBytes = utf8Bytes(message);
|
||||
@@ -598,8 +598,8 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
@@ -607,9 +607,9 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text }) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for rating');
|
||||
function makeTextRatingBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text }) {
|
||||
const cleanLogin = String(toLogin || '').trim();
|
||||
if (!cleanLogin) throw new Error('toLogin is required for rating');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
@@ -619,9 +619,9 @@ function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashH
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Rating text is required');
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
const loginBytes = utf8Bytes(cleanLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) {
|
||||
throw new Error('toLogin must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const textBytes = utf8Bytes(message);
|
||||
@@ -630,8 +630,8 @@ function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashH
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
@@ -639,18 +639,18 @@ function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashH
|
||||
);
|
||||
}
|
||||
|
||||
function makeStatusActionBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text = '' }) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for status action');
|
||||
function makeStatusActionBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text = '' }) {
|
||||
const cleanLogin = String(toLogin || '').trim();
|
||||
if (!cleanLogin) throw new Error('toLogin is required for status action');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for status action');
|
||||
}
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
const loginBytes = utf8Bytes(cleanLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) {
|
||||
throw new Error('toLogin must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const textBytes = utf8Bytes(String(text || '').trim());
|
||||
@@ -659,8 +659,8 @@ function makeStatusActionBodyBytes({ toBlockchainName, toBlockNumber, toBlockHas
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
@@ -673,7 +673,7 @@ function makeTextRepostBodyBytes({
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toBlockchainName,
|
||||
toLogin,
|
||||
toBlockNumber,
|
||||
toBlockHashHex,
|
||||
text,
|
||||
@@ -681,11 +681,11 @@ function makeTextRepostBodyBytes({
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Комментарий к репосту обязателен');
|
||||
|
||||
const bch = String(toBlockchainName || '').trim();
|
||||
if (!bch) throw new Error('toBlockchainName is required for repost');
|
||||
const bchBytes = utf8Bytes(bch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
const cleanTargetLogin = String(toLogin || '').trim();
|
||||
if (!cleanTargetLogin) throw new Error('toLogin is required for repost');
|
||||
const loginBytes = utf8Bytes(cleanTargetLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) {
|
||||
throw new Error('toLogin must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
@@ -703,8 +703,8 @@ function makeTextRepostBodyBytes({
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
@@ -717,11 +717,16 @@ function makeTextEditPostBodyBytes({
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toLogin,
|
||||
toBlockNumber,
|
||||
toBlockHashHex,
|
||||
text,
|
||||
}) {
|
||||
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);
|
||||
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
|
||||
throw new Error('Invalid target block number for edit post');
|
||||
@@ -736,6 +741,8 @@ function makeTextEditPostBodyBytes({
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(targetBlockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
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 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);
|
||||
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
|
||||
throw new Error('Invalid target block number for edit reply');
|
||||
@@ -755,6 +766,8 @@ function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(targetBlockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
@@ -767,21 +780,21 @@ function makeConnectionBodyBytes({
|
||||
prevLineNumber = -1,
|
||||
prevLineHashHex = ZERO64,
|
||||
thisLineNumber = -1,
|
||||
toBlockchainName,
|
||||
toLogin,
|
||||
toBlockNumber,
|
||||
toBlockHashHex,
|
||||
}) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for connection');
|
||||
const cleanTargetLogin = String(toLogin || '').trim();
|
||||
if (!cleanTargetLogin) throw new Error('toLogin is required for connection');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for connection');
|
||||
}
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
const loginBytes = utf8Bytes(cleanTargetLogin);
|
||||
if (loginBytes.length < 1 || loginBytes.length > 255) {
|
||||
throw new Error('toLogin must be 1..255 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
@@ -789,8 +802,8 @@ function makeConnectionBodyBytes({
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int8Byte(loginBytes.length),
|
||||
loginBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex))
|
||||
);
|
||||
@@ -938,13 +951,20 @@ function makeTextLineBodyBytesAllowEmpty({ lineCode, prevLineNumber, prevLineHas
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMessageRefTarget(target, actionName = 'action') {
|
||||
const cleanBch = String(target?.blockchainName || '').trim();
|
||||
const cleanBlockNumber = Number(target?.blockNumber);
|
||||
const cleanBlockHash = String(target?.blockHash || '').trim().toLowerCase();
|
||||
function loginFromBlockchainNameValue(value) {
|
||||
const name = String(value || '').trim();
|
||||
const match = name.match(/^(.*)-\d{3}$/u);
|
||||
return match?.[1] ? match[1] : '';
|
||||
}
|
||||
|
||||
if (!cleanBch) {
|
||||
throw new Error(`Missing message target blockchain for ${actionName}`);
|
||||
function normalizeMessageRefTarget(target, actionName = 'action') {
|
||||
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) {
|
||||
throw new Error(`Invalid message target block number for ${actionName}`);
|
||||
@@ -954,6 +974,7 @@ function normalizeMessageRefTarget(target, actionName = 'action') {
|
||||
}
|
||||
|
||||
return {
|
||||
login: cleanLogin,
|
||||
blockchainName: cleanBch,
|
||||
blockNumber: cleanBlockNumber,
|
||||
blockHash: cleanBlockHash,
|
||||
@@ -1892,7 +1913,7 @@ export class AuthService {
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeReactionLikeBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
});
|
||||
@@ -1915,7 +1936,7 @@ export class AuthService {
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeReactionLikeBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
});
|
||||
@@ -1939,7 +1960,7 @@ export class AuthService {
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeTextReplyBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
@@ -1964,7 +1985,7 @@ export class AuthService {
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeTextRatingBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
@@ -2004,7 +2025,7 @@ export class AuthService {
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeStatusActionBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
@@ -2077,7 +2098,7 @@ export class AuthService {
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toBlockchainName: target.blockchainName,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
@@ -2159,6 +2180,7 @@ export class AuthService {
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
@@ -2176,6 +2198,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const bodyBytes = makeTextEditReplyBodyBytes({
|
||||
toLogin: target.login,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
@@ -2206,6 +2229,7 @@ export class AuthService {
|
||||
return this.addBlockFollowChannel({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
targetLogin: cleanTargetLogin,
|
||||
targetBlockchainName: targetUser.blockchainName,
|
||||
targetBlockNumber: 0,
|
||||
targetBlockHashHex: targetHeaderHash,
|
||||
@@ -2217,6 +2241,7 @@ export class AuthService {
|
||||
async addBlockFollowChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
targetLogin = '',
|
||||
targetBlockchainName,
|
||||
targetBlockNumber,
|
||||
targetBlockHashHex,
|
||||
@@ -2224,8 +2249,10 @@ export class AuthService {
|
||||
}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanTargetBch = String(targetBlockchainName || '').trim();
|
||||
const cleanTargetLogin = String(targetLogin || loginFromBlockchainNameValue(cleanTargetBch)).trim();
|
||||
const cleanTargetBlockNumber = Number(targetBlockNumber);
|
||||
if (!cleanTargetBch) throw new Error('Target blockchain is required');
|
||||
if (!cleanTargetLogin) throw new Error('Target login is required');
|
||||
if (!Number.isFinite(cleanTargetBlockNumber) || cleanTargetBlockNumber < 0) {
|
||||
throw new Error('Invalid target block number');
|
||||
}
|
||||
@@ -2245,7 +2272,7 @@ export class AuthService {
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO64,
|
||||
thisLineNumber: -1,
|
||||
toBlockchainName: cleanTargetBch,
|
||||
toLogin: cleanTargetLogin,
|
||||
toBlockNumber: cleanTargetBlockNumber,
|
||||
toBlockHashHex: targetHashHex,
|
||||
});
|
||||
@@ -3228,13 +3255,12 @@ export class AuthService {
|
||||
|
||||
const targetUser = await this.getUser(cleanToLogin);
|
||||
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
|
||||
const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();
|
||||
const bodyBytes = makeConnectionBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO_HASH_HEX,
|
||||
thisLineNumber: -1,
|
||||
toBlockchainName,
|
||||
toLogin: cleanToLogin,
|
||||
toBlockNumber: 0,
|
||||
toBlockHashHex: ZERO_HASH_HEX,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
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 total=cutoff+2;
|
||||
if(Number(rotation.progressCurrent)||0 >= total) {
|
||||
onProgress({ phase:'copy', current:total, total });
|
||||
return;
|
||||
}
|
||||
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 techTimestampMs=Number(rotation.createdAtMs)||Number(parentTipTimestampMs)+1000;
|
||||
const techFrame=makeFrame({ prevHash:rotation.forkFromHash, blockNumber:cutoff+1, type:0, subType:TECH_FORK_SUBTYPE, version:1, body:forkBody, timestampMs:techTimestampMs });
|
||||
const techRaw=await createAns104DataItem({ owner32, privateKey, data:techFrame, tags:[{name:'App',value:'test5590'}] });
|
||||
await this.addCandidate({ blockNumber:cutoff+1, prevBlockHash:rotation.forkFromHash, blockBytesB64:bytesToBase64(techRaw) });
|
||||
onProgress({ phase:'copy', current:total, total });
|
||||
}
|
||||
|
||||
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; }
|
||||
@@ -557,7 +557,12 @@ async function createShineUserPdaOnSolana({
|
||||
const clientKey32 = base64ToBytes(keyBundle.clientPair.publicKeyB64);
|
||||
const rootPriv = await importPkcs8Ed25519(keyBundle.rootPair.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');
|
||||
if (!ecoInfo?.data) throw new Error('Economy config не инициализирован.');
|
||||
const startBonusLimit = parseUsersEconomyConfig(new Uint8Array(ecoInfo.data)).startBonusLimit;
|
||||
@@ -603,7 +608,7 @@ async function createShineUserPdaOnSolana({
|
||||
const createIx = new ctx.solana.TransactionInstruction({
|
||||
programId: ctx.usersProgram,
|
||||
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.solana.SystemProgram.programId, isSigner: false, isWritable: false },
|
||||
{ pubkey: ctx.inflowVault, isSigner: false, isWritable: true },
|
||||
@@ -618,7 +623,7 @@ async function createShineUserPdaOnSolana({
|
||||
const tx = new ctx.solana.Transaction();
|
||||
if (promoEdIx) tx.add(promoEdIx);
|
||||
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` };
|
||||
} catch (error) { throw await attachSolanaLogs(error, ctx.connection); }
|
||||
}
|
||||
@@ -678,15 +683,27 @@ export async function updateShineUserPdaOnSolana({
|
||||
accessServers,
|
||||
}) {
|
||||
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);
|
||||
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 clientKey = nextClientPublicKey32 ? toUint8Array(nextClientPublicKey32, 32) : current.clientKey;
|
||||
const rootChanged = bytesToBase58(rootKey) !== bytesToBase58(current.rootKey);
|
||||
if (authMode === AUTH_MODE_BLOCKCHAIN && rootChanged) throw new Error('Blockchain authority не может менять root key');
|
||||
if (rootChanged && newBlockchainPublicKey32) throw new Error('Root rotation и blockchain fork нужно делать разными транзакциями');
|
||||
const clientChanged = bytesToBase58(clientKey) !== bytesToBase58(current.clientKey);
|
||||
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 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);
|
||||
if (forks.some((f) => bytesToBase58(f.blockchainKey) === bytesToBase58(appendedKey))) throw new Error('Этот blockchain key уже был в истории fork');
|
||||
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;
|
||||
if (newLimit > 0xffffffffn) throw new Error('paid_limit_bytes превышает u32');
|
||||
forks.push({ blockchainKey: appendedKey, createdAtMs: updatedAtMs, paidLimitBytes: newLimit });
|
||||
@@ -728,23 +745,17 @@ export async function updateShineUserPdaOnSolana({
|
||||
const unsigned = serializeUnsignedRecordFromState(next);
|
||||
const hash = await sha256Bytes(unsigned);
|
||||
|
||||
const oldAuthorityPub = authMode === AUTH_MODE_ROOT ? current.rootKey : current.forks.at(-1).blockchainKey;
|
||||
const oldAuthorityPrivB64 = authMode === AUTH_MODE_ROOT ? rootPrivatePkcs8B64 : blockchainPrivatePkcs8B64;
|
||||
if (!oldAuthorityPrivB64) throw new Error(authMode === AUTH_MODE_ROOT ? 'Нужен root private key' : 'Нужен blockchain private key');
|
||||
const oldAuthorityPub = fullKeyRotation ? current.rootKey : current.forks.at(-1).blockchainKey;
|
||||
const oldAuthorityPrivB64 = fullKeyRotation ? rootPrivatePkcs8B64 : blockchainPrivatePkcs8B64;
|
||||
if (!oldAuthorityPrivB64) throw new Error(fullKeyRotation ? 'Для полной ротации нужен старый root private key' : 'Для обновления PDA нужен blockchain private key');
|
||||
const oldAuthorityPriv = await importPkcs8Ed25519(oldAuthorityPrivB64);
|
||||
const authSig = await signBytes(oldAuthorityPriv, hash);
|
||||
|
||||
let recordSignerPub;
|
||||
let recordSignerPrivB64;
|
||||
if (rootChanged) {
|
||||
recordSignerPub = rootKey;
|
||||
recordSignerPrivB64 = newRootPrivatePkcs8B64;
|
||||
} else if (appendedKey) {
|
||||
if (appendedKey) {
|
||||
recordSignerPub = appendedKey;
|
||||
recordSignerPrivB64 = newBlockchainPrivatePkcs8B64;
|
||||
} else if (authMode === AUTH_MODE_ROOT) {
|
||||
recordSignerPub = rootKey;
|
||||
recordSignerPrivB64 = rootPrivatePkcs8B64;
|
||||
} else {
|
||||
recordSignerPub = current.forks.at(-1).blockchainKey;
|
||||
recordSignerPrivB64 = blockchainPrivatePkcs8B64;
|
||||
@@ -758,7 +769,7 @@ export async function updateShineUserPdaOnSolana({
|
||||
const updateIx = new ctx.solana.TransactionInstruction({
|
||||
programId: ctx.usersProgram,
|
||||
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.solana.SystemProgram.programId, isSigner: false, isWritable: false },
|
||||
{ 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 }),
|
||||
});
|
||||
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 };
|
||||
} catch (error) { throw await attachSolanaLogs(error, ctx.connection); }
|
||||
}
|
||||
@@ -778,8 +789,9 @@ export async function updateServerOnSolana({ login, keyBundle, serverAddress, se
|
||||
login,
|
||||
solanaEndpoint,
|
||||
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
|
||||
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
|
||||
authorityMode: 'root',
|
||||
authorityMode: 'blockchain',
|
||||
serverAddresses: serverAddresses || [{ addressFormatType, addressFormatVersion, address: serverAddress }],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
const cleanLogin = String(login || '').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 cleanPwd = String(storagePwd || '').trim();
|
||||
if (!cleanLogin || !cleanPwd) {
|
||||
throw new Error('Нет активной сессии для доступа к ключам');
|
||||
}
|
||||
|
||||
const choices = [];
|
||||
const clientWallet = await getWalletFromStoredClientKey({ login: cleanLogin, storagePwd: cleanPwd });
|
||||
choices.push({
|
||||
id: 'client-key',
|
||||
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;
|
||||
if (!cleanLogin || !cleanPwd) throw new Error('Нет активной сессии для доступа к ключам');
|
||||
const wallet = await getWalletFromStoredBlockchainKey({ login: cleanLogin, storagePwd: cleanPwd });
|
||||
return [{
|
||||
id: 'blockchain-key',
|
||||
keySource: 'blockchain',
|
||||
label: 'blockchain key',
|
||||
address: wallet.address,
|
||||
keypair: wallet.keypair,
|
||||
}];
|
||||
}
|
||||
|
||||
export async function encodeSolanaSecretKeyBase58(secretKey) {
|
||||
|
||||
@@ -25,8 +25,8 @@ function parseSolToLamports(amountSol) {
|
||||
return lamports.toString();
|
||||
}
|
||||
|
||||
function normalizeKeySource(value) {
|
||||
return String(value || '').trim().toLowerCase() === 'root' ? 'root' : 'client';
|
||||
function normalizeKeySource() {
|
||||
return 'blockchain';
|
||||
}
|
||||
|
||||
async function loadTurboLib() {
|
||||
@@ -36,14 +36,12 @@ async function loadTurboLib() {
|
||||
return turboLibPromise;
|
||||
}
|
||||
|
||||
async function getTurboWalletChoice({ login, storagePwd, keySource = 'client' } = {}) {
|
||||
async function getTurboWalletChoice({ login, storagePwd, keySource = 'blockchain' } = {}) {
|
||||
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;
|
||||
if (!selected?.keypair) {
|
||||
throw new Error(normalizedKeySource === 'root'
|
||||
? 'На устройстве не найден root key для Turbo.'
|
||||
: 'На устройстве не найден client key для Turbo.');
|
||||
throw new Error('На устройстве не найден blockchain key для Turbo.');
|
||||
}
|
||||
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 turbo = await createTurboClientFromWalletChoice(choice);
|
||||
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 balance = await ctx.turbo.getBalance(ctx.address);
|
||||
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);
|
||||
if (!Number.isInteger(bytes) || bytes <= 0) {
|
||||
throw new Error('Некорректный размер файла для Turbo.');
|
||||
@@ -147,7 +145,7 @@ export async function estimateTurboUploadPrice({ login, storagePwd, keySource =
|
||||
export async function uploadFileWithTurbo({
|
||||
login,
|
||||
storagePwd,
|
||||
keySource = 'client',
|
||||
keySource = 'blockchain',
|
||||
file,
|
||||
tags = [],
|
||||
shineType = 'attachment',
|
||||
@@ -191,7 +189,7 @@ export async function uploadFileWithTurbo({
|
||||
export async function topUpTurboWithSolana({
|
||||
login,
|
||||
storagePwd,
|
||||
keySource = 'client',
|
||||
keySource = 'blockchain',
|
||||
amountSol,
|
||||
turboCreditDestinationAddress = '',
|
||||
} = {}) {
|
||||
|
||||
@@ -200,7 +200,7 @@ $('btnUpdate').addEventListener('click', async () => {
|
||||
if (!serverAddress) throw new Error('Укажите адрес сервера');
|
||||
|
||||
setStatus($('status'), 'Проверка и сборка keyBundle...', 'info');
|
||||
const { keyBundle, normalized } = await buildKeyBundleFromForm(fieldMap, { requireBlockchain: false });
|
||||
const { keyBundle, normalized } = await buildKeyBundleFromForm(fieldMap, { requireBlockchain: true });
|
||||
$('rootPub').value = normalized.rootPubB58;
|
||||
$('rootPriv').value = normalized.rootPrivB58;
|
||||
$('bchPub').value = normalized.bchPubB58;
|
||||
|
||||
@@ -64,7 +64,7 @@ block_version: u8 = 0
|
||||
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
|
||||
|
||||
@@ -74,7 +74,7 @@ block_version: u8 = 0
|
||||
client_key: [u8;32]
|
||||
```
|
||||
|
||||
Client key не является authority PDA. Он может использоваться клиентским уровнем и как fee payer Solana-транзакции.
|
||||
Client key не является authority PDA. Он может использоваться клиентским уровнем и как fee payer Solana-транзакции. В текущем протоколе смена `client_key` разрешена только как часть полной ротации вместе с root и новым blockchain fork.
|
||||
|
||||
## BlockchainRegistryBlock
|
||||
|
||||
@@ -148,12 +148,13 @@ PDA 1.2 разрешает `server_count = 0` или `1`. Count оставлен
|
||||
|
||||
- обычный update — активный blockchain key;
|
||||
- новый fork — новый blockchain key;
|
||||
- root-mode — root нового состояния;
|
||||
- смена root — новый root.
|
||||
- полная ротация root + client + blockchain fork — новый blockchain key.
|
||||
|
||||
На 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
|
||||
|
||||
|
||||
@@ -84,17 +84,22 @@ record_signature:[64]
|
||||
|
||||
Blockchain-mode:
|
||||
|
||||
- используется для всех обычных update;
|
||||
- старый authority = последний blockchain key;
|
||||
- root менять нельзя;
|
||||
- `signature[64]` нового состояния = активный blockchain key либо новый blockchain key, если добавляется fork;
|
||||
- root/client менять нельзя;
|
||||
- новый fork — только после 72 часов от времени активного fork.
|
||||
|
||||
Root-mode:
|
||||
|
||||
- старый authority = root;
|
||||
- root можно менять;
|
||||
- recovery fork может обходить 72-часовой cooldown.
|
||||
- допустим только для recovery/полной ротации ключей;
|
||||
- старый authority = текущий root;
|
||||
- полная ротация одной транзакцией меняет `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`
|
||||
Без изменений.
|
||||
|
||||
@@ -90,6 +90,7 @@ enum ShineUsersError {
|
||||
CannotCloseCurrentFormat = 37,
|
||||
RootChangeRequiresRoot = 38,
|
||||
AmbiguousAuthorityRotation = 39,
|
||||
RootModeRequiresFullRotation = 40,
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let root_changed = args.root_key != old_record.root_key;
|
||||
if args.auth_mode == AUTH_MODE_BLOCKCHAIN {
|
||||
require!(!root_changed, ShineUsersError::RootChangeRequiresRoot);
|
||||
}
|
||||
if root_changed && args.new_blockchain_key.is_some() {
|
||||
return Err(ProgramError::from(ShineUsersError::AmbiguousAuthorityRotation));
|
||||
let client_changed = args.client_key != old_record.client_key;
|
||||
let full_key_rotation = root_changed && client_changed && args.new_blockchain_key.is_some();
|
||||
|
||||
// root/client меняются только вместе с созданием нового blockchain fork.
|
||||
// Обычные 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_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 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 {
|
||||
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)?;
|
||||
}
|
||||
|
||||
@@ -664,12 +670,8 @@ fn process_update_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args:
|
||||
signature: [0; 64],
|
||||
};
|
||||
|
||||
let record_signer = if root_changed {
|
||||
new_record.root_key
|
||||
} else if let Some(new_key) = appended_key {
|
||||
let record_signer = if let Some(new_key) = appended_key {
|
||||
new_key
|
||||
} else if args.auth_mode == AUTH_MODE_ROOT {
|
||||
new_record.root_key
|
||||
} else {
|
||||
new_record.active_fork()?.blockchain_key
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user