Compare commits

...

10 Commits

25 changed files with 957 additions and 154 deletions

View File

@ -0,0 +1,26 @@
## Кратко
Исправлена ESP32-ветка обновления `user_pda` для добавления `homeserver`-сессии после миграции формата PDA на `RecoveryKeyBlock`.
## Что сделано
- В `shine_homeserver_main.ino` синхронизирован `create/update` payload с новым форматом `shine_users`.
- В сериализацию и парсинг PDA добавлен `RecoveryKeyBlock`.
- Для ветки `Add Homeserver` добавлены промежуточные checkpoint-записи в NVS, чтобы после crash или reset было видно, на каком шаге оборвалась операция.
- В `ESP32/AGENTS.md` добавлена памятка по чтению `last_error`.
## Что проверять
- Зарегистрировать или использовать уже существующий аккаунт на ESP32.
- Дойти до состояния `homeserver not in PDA`.
- Нажать `Add Homeserver`.
- Если операция не успешна, считать `last_error` по USB serial и убедиться, что видна свежая запись именно по шагам `Homeserver PDA update ...`, а не старый diag.
## Ожидаемый результат
- `Add Homeserver` добавляет `homeserver1` в `sessions` блока `SessionsBlock`.
- Если операция падает, в NVS сохраняется свежая диагностическая запись с текущим этапом, а не устаревший лог регистрации.
## Статус
`pending`

View File

@ -15,3 +15,14 @@
- Основной способ проверки и прошивки скетчей для `ESP32-S3-Touch-AMOLED-2.16` - `main-device/burn.sh`. - Основной способ проверки и прошивки скетчей для `ESP32-S3-Touch-AMOLED-2.16` - `main-device/burn.sh`.
- Не собирать эти скетчи напрямую через `arduino-cli compile` без `burn.sh`, потому что скрипт добавляет нужные локальные библиотеки и конфиги из `official-demo/examples/Arduino-v3.3.5/libraries`. - Не собирать эти скетчи напрямую через `arduino-cli compile` без `burn.sh`, потому что скрипт добавляет нужные локальные библиотеки и конфиги из `official-demo/examples/Arduino-v3.3.5/libraries`.
- Если сборка падает по `lv_conf.h` или `TouchDrvCSTXXX.hpp`, сначала проверять именно `burn.sh` и его `--library` пути, а не считать, что файл пропал из репозитория. - Если сборка падает по `lv_conf.h` или `TouchDrvCSTXXX.hpp`, сначала проверять именно `burn.sh` и его `--library` пути, а не считать, что файл пропал из репозитория.
## Диагностика ESP32
- Последнюю сохранённую ошибку или диагностическую запись читать с устройства через USB serial monitor на `115200`.
- Базовая команда:
`arduino-cli monitor -p /dev/ttyACM0 --config baudrate=115200`
- После подключения отправлять одну из команд:
`last_error`, `last_diag` или `reg_diag`
- Для очистки сохранённой диагностики использовать:
`clear_error` или `clear_diag`
- При падениях в ветках регистрации и обновления PDA сначала читать именно `last_error`: запись хранится в NVS и может пережить перезагрузку устройства.

View File

@ -12,6 +12,8 @@
#include <mbedtls/base64.h> #include <mbedtls/base64.h>
#include <Ed25519.h> #include <Ed25519.h>
#include <sodium.h> #include <sodium.h>
#include <time.h>
#include <sys/time.h>
#include <vector> #include <vector>
#define XPOWERS_CHIP_AXP2101 #define XPOWERS_CHIP_AXP2101
#include "XPowersLib.h" #include "XPowersLib.h"
@ -80,6 +82,8 @@ static const char *kUsersEconomyConfigSeed = "shine_users_economy_config";
static const char *kPaymentsInflowSeed = "shine_payments_inflow_vault"; static const char *kPaymentsInflowSeed = "shine_payments_inflow_vault";
static const char *kLastBlockPrefix = "SHiNE_LAST_BLOCK"; static const char *kLastBlockPrefix = "SHiNE_LAST_BLOCK";
static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress"; static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress";
static const char *kDefaultShineServerLogin = "shineupme";
static const uint8_t kBlockTypeRecoveryKey = 0;
static const uint8_t kBlockTypeRootKey = 1; static const uint8_t kBlockTypeRootKey = 1;
static const uint8_t kBlockTypeClientKey = 2; static const uint8_t kBlockTypeClientKey = 2;
static const uint8_t kBlockTypeBlockchainRegistry = 3; static const uint8_t kBlockTypeBlockchainRegistry = 3;
@ -229,6 +233,7 @@ struct ShinePdaUserState {
std::vector<String> accessServers; std::vector<String> accessServers;
uint8_t sessionsMode = 1; uint8_t sessionsMode = 1;
uint8_t trustedCount = 0; uint8_t trustedCount = 0;
uint8_t recoveryKey32[32] = {};
uint8_t rootKey32[32] = {}; uint8_t rootKey32[32] = {};
uint8_t clientKey32[32] = {}; uint8_t clientKey32[32] = {};
uint8_t blockchainKey32[32] = {}; uint8_t blockchainKey32[32] = {};
@ -320,8 +325,10 @@ static int gScanResultCount = 0;
static WifiViewMode gWifiViewMode = WIFI_VIEW_OVERVIEW; static WifiViewMode gWifiViewMode = WIFI_VIEW_OVERVIEW;
static String gSolanaRpcUrl = "https://api.devnet.solana.com"; static String gSolanaRpcUrl = "https://api.devnet.solana.com";
static String gShineServerUrl = "https://shineup.me"; static String gShineServerLogin = kDefaultShineServerLogin;
static String gServerStatusMessage = "Edit RPC or shine host"; static String gShineServerUrl;
static String gResolvedShineServerLogin;
static String gServerStatusMessage = "Edit RPC or server login";
static String gLoginValue; static String gLoginValue;
static String gHomeserverValue = "homeserver1"; static String gHomeserverValue = "homeserver1";
static bool gSecretConfigured = false; static bool gSecretConfigured = false;
@ -345,6 +352,9 @@ static unsigned long gLastAccountCheckMs = 0;
static bool gShowRegisterAccountButton = false; static bool gShowRegisterAccountButton = false;
static bool gShowHomeserverPdaActionButton = false; static bool gShowHomeserverPdaActionButton = false;
static String gHomeserverPdaActionReason; static String gHomeserverPdaActionReason;
static bool gCachedAccountPdaValid = false;
static String gCachedAccountPdaLogin;
static ShinePdaUserState gCachedAccountPdaState;
static String gUserPdaAddress; static String gUserPdaAddress;
static String gRegistrationSignature; static String gRegistrationSignature;
static String gShineStatusLine = "SHiNE: account not configured"; static String gShineStatusLine = "SHiNE: account not configured";
@ -392,6 +402,8 @@ struct DerivedKeyInfo {
static String gRootPubB58; static String gRootPubB58;
static String gRootPrivB58; static String gRootPrivB58;
static String gRecoveryPubB58;
static String gRecoveryPrivB58;
static String gBlockchainPubB58; static String gBlockchainPubB58;
static String gBlockchainPrivB58; static String gBlockchainPrivB58;
static String gDevicePubB58; static String gDevicePubB58;
@ -407,6 +419,9 @@ static const int kWalletRpcSignalTypeRequest = 9100;
static const int kWalletRpcSignalTypeResponse = 9101; static const int kWalletRpcSignalTypeResponse = 9101;
static ActiveWalletSignRequest gActiveWalletSignRequest; static ActiveWalletSignRequest gActiveWalletSignRequest;
static String gWalletSignStatusMessage; static String gWalletSignStatusMessage;
static lv_indev_drv_t *gSecretScrollIndevDriver = nullptr;
static uint8_t gSecretScrollThrowSaved = 0;
static bool gSecretScrollThrowApplied = false;
static EditContext gEditContext = EDIT_CONTEXT_NONE; static EditContext gEditContext = EDIT_CONTEXT_NONE;
static Screen gEditReturnScreen = SCREEN_HOME; static Screen gEditReturnScreen = SCREEN_HOME;
@ -517,6 +532,11 @@ static void pushU64LE(std::vector<uint8_t> &out, uint64_t value);
static void pushStrU8(std::vector<uint8_t> &out, const String &value); static void pushStrU8(std::vector<uint8_t> &out, const String &value);
static void pushFixed(std::vector<uint8_t> &out, const uint8_t *data, size_t len); static void pushFixed(std::vector<uint8_t> &out, const uint8_t *data, size_t len);
static String bytesToBase58(const uint8_t *data, size_t len); static String bytesToBase58(const uint8_t *data, size_t len);
static bool getSystemEpochMs(uint64_t &epochMsOut);
static bool ensureNtpTimeSynced(String &errorOut);
static bool resolveShineServerUrlFromLogin(const String &serverLogin, String &serverUrlOut, String &errorOut);
static String currentShineServerLoginSource();
static bool ensureCurrentShineServerUrl(String &errorOut);
static String buildBaseRpcRequest(const char *method, const String &paramsJson); static String buildBaseRpcRequest(const char *method, const String &paramsJson);
static bool rpcCallSolana(const char *method, const String &paramsJson, String &payloadOut); static bool rpcCallSolana(const char *method, const String &paramsJson, String &payloadOut);
static bool rpcResponseHasError(const String &payload); static bool rpcResponseHasError(const String &payload);
@ -540,7 +560,8 @@ static std::vector<uint8_t> buildLastBlockStateBytes(const String &login,
static std::vector<uint8_t> buildUnsignedCreateRecord( static std::vector<uint8_t> buildUnsignedCreateRecord(
const String &login, const String &login,
const String &blockchainName, const String &blockchainName,
const String &serverAddress, const std::vector<String> &accessServers,
const uint8_t recoveryPub[32],
const uint8_t rootPub[32], const uint8_t rootPub[32],
const uint8_t clientPub[32], const uint8_t clientPub[32],
const uint8_t blockchainPub[32], const uint8_t blockchainPub[32],
@ -550,7 +571,8 @@ static std::vector<uint8_t> buildUnsignedCreateRecord(
static std::vector<uint8_t> buildCreateInstructionData( static std::vector<uint8_t> buildCreateInstructionData(
const String &login, const String &login,
const String &blockchainName, const String &blockchainName,
const String &serverAddress, const std::vector<String> &accessServers,
const uint8_t recoveryPub[32],
const uint8_t rootPub[32], const uint8_t rootPub[32],
const uint8_t clientPub[32], const uint8_t clientPub[32],
const uint8_t blockchainPub[32], const uint8_t blockchainPub[32],
@ -610,6 +632,7 @@ static void loadRegisterDiagDetailsFromPrefs();
static void saveRegisterDiagDetailsToPrefs(const String &details); static void saveRegisterDiagDetailsToPrefs(const String &details);
static void clearRegisterDiagDetailsFromPrefs(); static void clearRegisterDiagDetailsFromPrefs();
static void saveRegisterDiag(const String &status, const String &summary, const String &details); static void saveRegisterDiag(const String &status, const String &summary, const String &details);
static void saveRegisterDiagCheckpoint(const String &summary, const String &details);
static void printRegisterDiagToSerial(); static void printRegisterDiagToSerial();
static void clearRegisterDiag(); static void clearRegisterDiag();
static void handleUsbSerialCommands(); static void handleUsbSerialCommands();
@ -877,6 +900,19 @@ static String normalizeLoginValue(const String &value) {
return out; return out;
} }
static bool isValidShineServerLoginValue(const String &value) {
if (value.isEmpty() || value.length() > 20) {
return false;
}
for (size_t i = 0; i < value.length(); ++i) {
char ch = value.charAt(i);
if (!(isAlphaNumeric((unsigned char)ch) || ch == '_')) {
return false;
}
}
return true;
}
static String abbreviateValue(const String &value, size_t head = 8, size_t tail = 6) { static String abbreviateValue(const String &value, size_t head = 8, size_t tail = 6) {
if (value.length() <= head + tail + 3) { if (value.length() <= head + tail + 3) {
return value; return value;
@ -1206,10 +1242,17 @@ static void deriveKeyPairFromSecretSuffix(const uint8_t *secret32, const String
if (!secret32) { if (!secret32) {
return; return;
} }
String material = base64Std(secret32, 32) + "|" + suffix; const char *prefix = "SHiNE-key";
uint8_t seed[32] = {}; uint8_t seed[32] = {};
uint8_t pub[32] = {}; uint8_t pub[32] = {};
sha256calc(reinterpret_cast<const uint8_t *>(material.c_str()), material.length(), seed); std::vector<uint8_t> material;
material.reserve(10 + 1 + 32 + 1 + suffix.length());
material.insert(material.end(), prefix, prefix + strlen(prefix));
material.push_back(0);
material.insert(material.end(), secret32, secret32 + 32);
material.push_back(0);
material.insert(material.end(), suffix.c_str(), suffix.c_str() + suffix.length());
sha256calc(material.data(), material.size(), seed);
Ed25519::derivePublicKey(pub, seed); Ed25519::derivePublicKey(pub, seed);
privB58 = base58From32(seed); privB58 = base58From32(seed);
pubB58 = base58From32(pub); pubB58 = base58From32(pub);
@ -1218,6 +1261,8 @@ static void deriveKeyPairFromSecretSuffix(const uint8_t *secret32, const String
static void clearDerivedKeys() { static void clearDerivedKeys() {
gRootPubB58 = ""; gRootPubB58 = "";
gRootPrivB58 = ""; gRootPrivB58 = "";
gRecoveryPubB58 = "";
gRecoveryPrivB58 = "";
gBlockchainPubB58 = ""; gBlockchainPubB58 = "";
gBlockchainPrivB58 = ""; gBlockchainPrivB58 = "";
gDevicePubB58 = ""; gDevicePubB58 = "";
@ -1233,6 +1278,7 @@ static void refreshDerivedKeys() {
if (!gSecretConfigured) { if (!gSecretConfigured) {
return; return;
} }
deriveKeyPairFromSecretSuffix(gSecretBytes, "recovery.key", gRecoveryPubB58, gRecoveryPrivB58);
deriveKeyPairFromSecretSuffix(gSecretBytes, "root.key", gRootPubB58, gRootPrivB58); deriveKeyPairFromSecretSuffix(gSecretBytes, "root.key", gRootPubB58, gRootPrivB58);
deriveKeyPairFromSecretSuffix(gSecretBytes, "blockchain.key", gBlockchainPubB58, gBlockchainPrivB58); deriveKeyPairFromSecretSuffix(gSecretBytes, "blockchain.key", gBlockchainPubB58, gBlockchainPrivB58);
deriveKeyPairFromSecretSuffix(gSecretBytes, "client.key", gDevicePubB58, gDevicePrivB58); deriveKeyPairFromSecretSuffix(gSecretBytes, "client.key", gDevicePubB58, gDevicePrivB58);
@ -1310,6 +1356,43 @@ static String selectedWalletPrivateKeyB58() {
} }
} }
static void secretScrollBoostSet(bool enable) {
if (enable) {
lv_indev_t *indev = lv_indev_get_act();
if (!indev || !indev->driver) {
return;
}
if (!gSecretScrollThrowApplied) {
gSecretScrollIndevDriver = indev->driver;
gSecretScrollThrowSaved = gSecretScrollIndevDriver->scroll_throw;
gSecretScrollIndevDriver->scroll_throw = (uint8_t)(gSecretScrollThrowSaved / 4);
gSecretScrollThrowApplied = true;
}
} else if (gSecretScrollThrowApplied && gSecretScrollIndevDriver) {
gSecretScrollIndevDriver->scroll_throw = gSecretScrollThrowSaved;
gSecretScrollIndevDriver = nullptr;
gSecretScrollThrowSaved = 0;
gSecretScrollThrowApplied = false;
}
}
static void secretScrollEventCb(lv_event_t *e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_SCROLL_BEGIN) {
lv_anim_t *anim = lv_event_get_scroll_anim(e);
if (anim && anim->time > 0) {
uint32_t boostedTime = (uint32_t)anim->time / 4;
if (boostedTime < 80U) {
boostedTime = 80U;
}
lv_anim_set_time(anim, boostedTime);
}
secretScrollBoostSet(true);
} else if (code == LV_EVENT_SCROLL_END || code == LV_EVENT_DELETE) {
secretScrollBoostSet(false);
}
}
static bool selectedWalletAvailable() { static bool selectedWalletAvailable() {
return !selectedWalletPublicKeyB58().isEmpty(); return !selectedWalletPublicKeyB58().isEmpty();
} }
@ -1396,6 +1479,9 @@ static void markAccountStateDirty() {
gHomeserverPdaResultMessage = ""; gHomeserverPdaResultMessage = "";
gHomeserverPdaResultDetails = ""; gHomeserverPdaResultDetails = "";
gHomeserverPdaResultSuccess = false; gHomeserverPdaResultSuccess = false;
gCachedAccountPdaValid = false;
gCachedAccountPdaLogin = "";
gCachedAccountPdaState = ShinePdaUserState{};
gUserPdaAddress = ""; gUserPdaAddress = "";
gRegistrationSignature = ""; gRegistrationSignature = "";
gRegisterConfirmMessage = ""; gRegisterConfirmMessage = "";
@ -1559,6 +1645,16 @@ static String balanceHomeLine() {
return gBalanceStatusMessage; return gBalanceStatusMessage;
} }
static String shineServerDisplayLabel() {
if (!gShineServerUrl.isEmpty()) {
return gShineServerUrl;
}
if (!currentShineServerLoginSource().isEmpty()) {
return currentShineServerLoginSource();
}
return "not set";
}
static String wifiHomeRichLine() { static String wifiHomeRichLine() {
String ssid = gWifiSavedSsid.isEmpty() ? String("not configured") : gWifiSavedSsid; String ssid = gWifiSavedSsid.isEmpty() ? String("not configured") : gWifiSavedSsid;
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
@ -1568,7 +1664,7 @@ static String wifiHomeRichLine() {
} }
static String shineHomeRichLine() { static String shineHomeRichLine() {
String serverLabel = gShineServerUrl.isEmpty() ? String("not set") : gShineServerUrl; String serverLabel = shineServerDisplayLabel();
if (gShineStatusLine.endsWith(" connected")) { if (gShineStatusLine.endsWith(" connected")) {
return String("SHiNE: ") + serverLabel + " #38B26D connected#"; return String("SHiNE: ") + serverLabel + " #38B26D connected#";
} }
@ -1648,6 +1744,96 @@ static uint64_t shineNowMs() {
return value > 0 ? (uint64_t)value : (uint64_t)millis(); return value > 0 ? (uint64_t)value : (uint64_t)millis();
} }
static bool getSystemEpochMs(uint64_t &epochMsOut) {
struct timeval tv {};
if (gettimeofday(&tv, nullptr) != 0) {
return false;
}
if (tv.tv_sec < 1700000000) {
return false;
}
epochMsOut = (uint64_t)tv.tv_sec * 1000ULL + (uint64_t)(tv.tv_usec / 1000ULL);
return true;
}
static bool ensureNtpTimeSynced(String &errorOut) {
errorOut = "";
uint64_t epochMs = 0;
if (getSystemEpochMs(epochMs)) {
return true;
}
configTime(0, 0, "pool.ntp.org", "time.cloudflare.com", "time.google.com");
for (int i = 0; i < 30; ++i) {
delay(500);
if (getSystemEpochMs(epochMs)) {
return true;
}
}
errorOut = "NTP time sync failed";
return false;
}
static bool resolveShineServerUrlFromLogin(const String &serverLogin, String &serverUrlOut, String &errorOut) {
errorOut = "";
serverUrlOut = "";
String cleanLogin = normalizeLoginValue(serverLogin);
if (cleanLogin.isEmpty()) {
errorOut = "Shine server login is not set";
return false;
}
ShinePdaUserState serverState;
if (!readShineUserPda(cleanLogin, serverState, errorOut)) {
if (errorOut.isEmpty()) {
errorOut = "Failed to read Shine server PDA";
}
return false;
}
if (!serverState.found) {
errorOut = "Shine server PDA not found";
return false;
}
if (!serverState.isServer) {
errorOut = "Shine server PDA is not a server";
return false;
}
if (serverState.serverAddress.isEmpty()) {
errorOut = "Shine server address is empty";
return false;
}
serverUrlOut = serverState.serverAddress;
return true;
}
static String currentShineServerLoginSource() {
if (gCachedAccountPdaValid && gCachedAccountPdaLogin == normalizeLoginValue(gLoginValue) && !gCachedAccountPdaState.accessServers.empty()) {
String fromPda = normalizeLoginValue(gCachedAccountPdaState.accessServers.front());
if (!fromPda.isEmpty()) {
return fromPda;
}
}
return normalizeLoginValue(gShineServerLogin);
}
static bool ensureCurrentShineServerUrl(String &errorOut) {
errorOut = "";
String login = currentShineServerLoginSource();
if (login.isEmpty()) {
errorOut = "Shine server login is not set";
return false;
}
if (gShineServerUrl.isEmpty() || gResolvedShineServerLogin != login) {
String resolvedUrl;
if (!resolveShineServerUrlFromLogin(login, resolvedUrl, errorOut)) {
return false;
}
gShineServerUrl = resolvedUrl;
gResolvedShineServerLogin = login;
}
return true;
}
static void shortVecEncode(size_t value, std::vector<uint8_t> &out) { static void shortVecEncode(size_t value, std::vector<uint8_t> &out) {
do { do {
uint8_t byte = value & 0x7F; uint8_t byte = value & 0x7F;
@ -1753,7 +1939,11 @@ static std::vector<uint8_t> serializeUnsignedRecordState(const ShinePdaUserState
pushU32LE(out, state.recordNumber); pushU32LE(out, state.recordNumber);
pushFixed(out, state.prevRecordHash32, 32); pushFixed(out, state.prevRecordHash32, 32);
pushStrU8(out, state.login); pushStrU8(out, state.login);
out.push_back(state.isServer ? 7 : 6); out.push_back(state.isServer ? 8 : 7);
out.push_back(kBlockTypeRecoveryKey);
out.push_back(0);
pushFixed(out, state.recoveryKey32, 32);
out.push_back(kBlockTypeRootKey); out.push_back(kBlockTypeRootKey);
out.push_back(0); out.push_back(0);
@ -1831,6 +2021,7 @@ static std::vector<uint8_t> buildUpdateInstructionData(const ShinePdaUserState &
out.reserve(640); out.reserve(640);
out.push_back(4); out.push_back(4);
pushStrU8(out, state.login); pushStrU8(out, state.login);
pushFixed(out, state.recoveryKey32, 32);
pushFixed(out, state.rootKey32, 32); pushFixed(out, state.rootKey32, 32);
pushU64LE(out, state.createdAtMs); pushU64LE(out, state.createdAtMs);
pushU64LE(out, updatedAtMs); pushU64LE(out, updatedAtMs);
@ -1880,7 +2071,8 @@ static std::vector<uint8_t> buildUpdateInstructionData(const ShinePdaUserState &
static std::vector<uint8_t> buildUnsignedCreateRecord( static std::vector<uint8_t> buildUnsignedCreateRecord(
const String &login, const String &login,
const String &blockchainName, const String &blockchainName,
const String &serverAddress, const std::vector<String> &accessServers,
const uint8_t recoveryPub[32],
const uint8_t rootPub[32], const uint8_t rootPub[32],
const uint8_t clientPub[32], const uint8_t clientPub[32],
const uint8_t blockchainPub[32], const uint8_t blockchainPub[32],
@ -1901,6 +2093,10 @@ static std::vector<uint8_t> buildUnsignedCreateRecord(
pushStrU8(out, login); pushStrU8(out, login);
out.push_back(7); out.push_back(7);
out.push_back(kBlockTypeRecoveryKey);
out.push_back(0);
pushFixed(out, recoveryPub, 32);
out.push_back(kBlockTypeRootKey); out.push_back(kBlockTypeRootKey);
out.push_back(0); out.push_back(0);
pushFixed(out, rootPub, 32); pushFixed(out, rootPub, 32);
@ -1922,17 +2118,12 @@ static std::vector<uint8_t> buildUnsignedCreateRecord(
pushFixed(out, lastBlockSignature, 64); pushFixed(out, lastBlockSignature, 64);
out.push_back(0); out.push_back(0);
out.push_back(kBlockTypeServerProfile);
out.push_back(0);
out.push_back(1);
out.push_back(0);
out.push_back(0);
pushStrU8(out, serverAddress);
out.push_back(0);
out.push_back(kBlockTypeAccessServers); out.push_back(kBlockTypeAccessServers);
out.push_back(0); out.push_back(0);
out.push_back(0); out.push_back((uint8_t)accessServers.size());
for (const auto &value : accessServers) {
pushStrU8(out, value);
}
out.push_back(kBlockTypeSessions); out.push_back(kBlockTypeSessions);
out.push_back(0); out.push_back(0);
@ -1952,7 +2143,8 @@ static std::vector<uint8_t> buildUnsignedCreateRecord(
static std::vector<uint8_t> buildCreateInstructionData( static std::vector<uint8_t> buildCreateInstructionData(
const String &login, const String &login,
const String &blockchainName, const String &blockchainName,
const String &serverAddress, const std::vector<String> &accessServers,
const uint8_t recoveryPub[32],
const uint8_t rootPub[32], const uint8_t rootPub[32],
const uint8_t clientPub[32], const uint8_t clientPub[32],
const uint8_t blockchainPub[32], const uint8_t blockchainPub[32],
@ -1963,6 +2155,7 @@ static std::vector<uint8_t> buildCreateInstructionData(
out.reserve(512); out.reserve(512);
out.push_back(3); out.push_back(3);
pushStrU8(out, login); pushStrU8(out, login);
pushFixed(out, recoveryPub, 32);
pushFixed(out, rootPub, 32); pushFixed(out, rootPub, 32);
pushU64LE(out, createdAtMs); pushU64LE(out, createdAtMs);
pushU64LE(out, 0); pushU64LE(out, 0);
@ -1974,12 +2167,11 @@ static std::vector<uint8_t> buildCreateInstructionData(
out.insert(out.end(), 32, 0); out.insert(out.end(), 32, 0);
pushFixed(out, lastBlockSignature, 64); pushFixed(out, lastBlockSignature, 64);
out.push_back(0); out.push_back(0);
out.push_back(1);
out.push_back(0);
out.push_back(0);
pushStrU8(out, serverAddress);
out.push_back(0);
out.push_back(0); out.push_back(0);
out.push_back((uint8_t)accessServers.size());
for (const auto &value : accessServers) {
pushStrU8(out, value);
}
out.push_back(1); out.push_back(1);
out.push_back(0); out.push_back(0);
out.push_back(0); out.push_back(0);
@ -2608,11 +2800,15 @@ static bool registerHomeserverOnSolana(String &messageOut) {
}; };
String cleanLogin = normalizeLoginValue(gLoginValue); String cleanLogin = normalizeLoginValue(gLoginValue);
String accessServerLogin = normalizeLoginValue(gShineServerLogin);
if (!isValidShineServerLoginValue(accessServerLogin)) {
accessServerLogin = kDefaultShineServerLogin;
}
diagDetails += String("trigger=") + gRegisterTriggerSource + "\n"; diagDetails += String("trigger=") + gRegisterTriggerSource + "\n";
diagDetails += String("test_uptime_ms=") + String(millis()) + "\n"; diagDetails += String("test_uptime_ms=") + String(millis()) + "\n";
diagDetails += String("login=") + cleanLogin + "\n"; diagDetails += String("login=") + cleanLogin + "\n";
diagDetails += String("rpc=") + gSolanaRpcUrl + "\n"; diagDetails += String("rpc=") + gSolanaRpcUrl + "\n";
diagDetails += String("shine_server=") + gShineServerUrl + "\n"; diagDetails += String("shine_server_login=") + accessServerLogin + "\n";
diagDetails += String("homeserver=") + gHomeserverValue + "\n"; diagDetails += String("homeserver=") + gHomeserverValue + "\n";
if (cleanLogin.isEmpty()) { if (cleanLogin.isEmpty()) {
@ -2640,7 +2836,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
gAccountPdaStatusMessage = "User is already registered"; gAccountPdaStatusMessage = "User is already registered";
gShowRegisterAccountButton = false; gShowRegisterAccountButton = false;
gAccountStatusMessage = "User is already registered"; gAccountStatusMessage = "User is already registered";
gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " registered"; gShineStatusLine = String("SHiNE: ") + (!gShineServerUrl.isEmpty() ? gShineServerUrl : accessServerLogin) + " registered";
refreshAccountPdaStatus(); refreshAccountPdaStatus();
diagDetails += String("user_pda=") + existingPda + "\n"; diagDetails += String("user_pda=") + existingPda + "\n";
saveRegisterDiag("ok", "User is already registered", diagDetails); saveRegisterDiag("ok", "User is already registered", diagDetails);
@ -2705,17 +2901,22 @@ static bool registerHomeserverOnSolana(String &messageOut) {
uint8_t rootSeed[32] = {}; uint8_t rootSeed[32] = {};
uint8_t rootPub[32] = {}; uint8_t rootPub[32] = {};
uint8_t rootSec[64] = {}; uint8_t rootSec[64] = {};
uint8_t recoverySeed[32] = {};
uint8_t recoveryPub[32] = {};
uint8_t recoverySec[64] = {};
uint8_t blockchainSeed[32] = {}; uint8_t blockchainSeed[32] = {};
uint8_t blockchainPub[32] = {}; uint8_t blockchainPub[32] = {};
uint8_t blockchainSec[64] = {}; uint8_t blockchainSec[64] = {};
uint8_t clientSeed[32] = {}; uint8_t clientSeed[32] = {};
uint8_t clientPub[32] = {}; uint8_t clientPub[32] = {};
uint8_t deviceSec[64] = {}; uint8_t deviceSec[64] = {};
if (!deriveSeedKeypairFromBase58(gRootPrivB58, rootSeed, rootPub, rootSec) || if (!deriveSeedKeypairFromBase58(gRecoveryPrivB58, recoverySeed, recoveryPub, recoverySec) ||
!deriveSeedKeypairFromBase58(gRootPrivB58, rootSeed, rootPub, rootSec) ||
!deriveSeedKeypairFromBase58(gBlockchainPrivB58, blockchainSeed, blockchainPub, blockchainSec) || !deriveSeedKeypairFromBase58(gBlockchainPrivB58, blockchainSeed, blockchainPub, blockchainSec) ||
!deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, deviceSec)) { !deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, deviceSec)) {
return failWithDiag("Failed to restore keys"); return failWithDiag("Failed to restore keys");
} }
diagDetails += String("recovery_pub=") + bytesToBase58(recoveryPub, 32) + "\n";
diagDetails += String("root_pub=") + bytesToBase58(rootPub, 32) + "\n"; diagDetails += String("root_pub=") + bytesToBase58(rootPub, 32) + "\n";
diagDetails += String("blockchain_pub=") + bytesToBase58(blockchainPub, 32) + "\n"; diagDetails += String("blockchain_pub=") + bytesToBase58(blockchainPub, 32) + "\n";
diagDetails += String("device_pub=") + bytesToBase58(clientPub, 32) + "\n"; diagDetails += String("device_pub=") + bytesToBase58(clientPub, 32) + "\n";
@ -2732,10 +2933,19 @@ static bool registerHomeserverOnSolana(String &messageOut) {
diagDetails += String("last_block_hash=") + bytesToHexString(lastBlockHash, 32) + "\n"; diagDetails += String("last_block_hash=") + bytesToHexString(lastBlockHash, 32) + "\n";
diagDetails += String("last_block_signature_b64=") + bytesToBase64String(lastBlockSignature, 64) + "\n"; diagDetails += String("last_block_signature_b64=") + bytesToBase64String(lastBlockSignature, 64) + "\n";
uint64_t createdAtMs = shineNowMs(); if (!ensureNtpTimeSynced(messageOut)) {
diagDetails += String("ntp_error=") + messageOut + "\n";
return failWithDiag(messageOut);
}
uint64_t createdAtMs = 0;
if (!getSystemEpochMs(createdAtMs)) {
return failWithDiag("NTP time is not ready");
}
diagDetails += String("created_at_ms=") + String((unsigned long long)createdAtMs) + "\n"; diagDetails += String("created_at_ms=") + String((unsigned long long)createdAtMs) + "\n";
std::vector<String> accessServers = {accessServerLogin};
std::vector<uint8_t> unsignedRecord = buildUnsignedCreateRecord( std::vector<uint8_t> unsignedRecord = buildUnsignedCreateRecord(
cleanLogin, blockchainName, gShineServerUrl, cleanLogin, blockchainName, accessServers,
recoveryPub,
rootPub, clientPub, blockchainPub, rootPub, clientPub, blockchainPub,
lastBlockSignature, startBonusLimit, createdAtMs); lastBlockSignature, startBonusLimit, createdAtMs);
uint8_t unsignedHash[32]; uint8_t unsignedHash[32];
@ -2749,7 +2959,8 @@ static bool registerHomeserverOnSolana(String &messageOut) {
diagDetails += String("root_signature_b64=") + bytesToBase64String(rootSignature, 64) + "\n"; diagDetails += String("root_signature_b64=") + bytesToBase64String(rootSignature, 64) + "\n";
std::vector<uint8_t> createData = buildCreateInstructionData( std::vector<uint8_t> createData = buildCreateInstructionData(
cleanLogin, blockchainName, gShineServerUrl, cleanLogin, blockchainName, accessServers,
recoveryPub,
rootPub, clientPub, blockchainPub, rootPub, clientPub, blockchainPub,
lastBlockSignature, rootSignature, createdAtMs); lastBlockSignature, rootSignature, createdAtMs);
std::vector<uint8_t> edRootData = buildEd25519InstructionData(rootSignature, rootPub, unsignedHash); std::vector<uint8_t> edRootData = buildEd25519InstructionData(rootSignature, rootPub, unsignedHash);
@ -2819,7 +3030,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
gAccountPdaStatus = ACCOUNT_PDA_OK; gAccountPdaStatus = ACCOUNT_PDA_OK;
gAccountPdaStatusMessage = "User registered"; gAccountPdaStatusMessage = "User registered";
gShowRegisterAccountButton = false; gShowRegisterAccountButton = false;
gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " registered"; gShineStatusLine = String("SHiNE: ") + (!gShineServerUrl.isEmpty() ? gShineServerUrl : accessServerLogin) + " registered";
saveAccountPrefs(); saveAccountPrefs();
refreshAccountPdaStatus(); refreshAccountPdaStatus();
messageOut = "Solana registration confirmed"; messageOut = "Solana registration confirmed";
@ -2983,6 +3194,7 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag
diagDetails += String("login=") + cleanLogin + "\n"; diagDetails += String("login=") + cleanLogin + "\n";
diagDetails += String("homeserver=") + gHomeserverValue + "\n"; diagDetails += String("homeserver=") + gHomeserverValue + "\n";
diagDetails += String("rpc=") + gSolanaRpcUrl + "\n"; diagDetails += String("rpc=") + gSolanaRpcUrl + "\n";
saveRegisterDiagCheckpoint("Homeserver PDA update started", diagDetails);
if (cleanLogin.isEmpty()) { if (cleanLogin.isEmpty()) {
return failWithDiag("Login is not set"); return failWithDiag("Login is not set");
@ -3001,14 +3213,26 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag
ShinePdaUserState currentState; ShinePdaUserState currentState;
String stateError; String stateError;
if (!readShineUserPda(cleanLogin, currentState, stateError)) { bool usedCachedPda = false;
diagDetails += String("read_pda_error=") + stateError + "\n"; if (gCachedAccountPdaValid && gCachedAccountPdaLogin == cleanLogin && gCachedAccountPdaState.found) {
return failWithDiag(stateError.isEmpty() ? "Failed to read user PDA" : stateError); currentState = gCachedAccountPdaState;
} usedCachedPda = true;
if (!currentState.found) { diagDetails += "read_pda_source=cache\n";
return failWithDiag("User PDA does not exist yet"); } else {
if (!readShineUserPda(cleanLogin, currentState, stateError)) {
diagDetails += String("read_pda_error=") + stateError + "\n";
return failWithDiag(stateError.isEmpty() ? "Failed to read user PDA" : stateError);
}
if (!currentState.found) {
return failWithDiag("User PDA does not exist yet");
}
diagDetails += "read_pda_source=rpc\n";
} }
saveRegisterDiagCheckpoint(usedCachedPda ? "Homeserver PDA read from cache" : "Homeserver PDA read", diagDetails);
uint8_t recoverySeed[32] = {};
uint8_t recoveryPub[32] = {};
uint8_t recoverySec[64] = {};
uint8_t rootSeed[32] = {}; uint8_t rootSeed[32] = {};
uint8_t rootPub[32] = {}; uint8_t rootPub[32] = {};
uint8_t rootSec[64] = {}; uint8_t rootSec[64] = {};
@ -3016,14 +3240,19 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag
uint8_t clientPub[32] = {}; uint8_t clientPub[32] = {};
uint8_t deviceSec[64] = {}; uint8_t deviceSec[64] = {};
uint8_t homeserverPub[32] = {}; uint8_t homeserverPub[32] = {};
if (!deriveSeedKeypairFromBase58(gRootPrivB58, rootSeed, rootPub, rootSec) if (!deriveSeedKeypairFromBase58(gRecoveryPrivB58, recoverySeed, recoveryPub, recoverySec)
|| !deriveSeedKeypairFromBase58(gRootPrivB58, rootSeed, rootPub, rootSec)
|| !deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, deviceSec) || !deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, deviceSec)
|| !base58ToFixed32(gHomeserverPubB58, homeserverPub)) { || !base58ToFixed32(gHomeserverPubB58, homeserverPub)) {
return failWithDiag("Failed to restore local keys"); return failWithDiag("Failed to restore local keys");
} }
if (memcmp(recoveryPub, currentState.recoveryKey32, 32) != 0) {
return failWithDiag("Recovery key does not match PDA");
}
if (memcmp(clientPub, currentState.clientKey32, 32) != 0) { if (memcmp(clientPub, currentState.clientKey32, 32) != 0) {
return failWithDiag("Client key does not match PDA"); return failWithDiag("Client key does not match PDA");
} }
saveRegisterDiagCheckpoint("Local keys restored", diagDetails);
uint8_t userPda[32] = {}; uint8_t userPda[32] = {};
uint8_t economyConfig[32] = {}; uint8_t economyConfig[32] = {};
@ -3047,6 +3276,7 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag
diagDetails += String("inflow_vault_pda=") + bytesToBase58(inflowVault, 32) + "\n"; diagDetails += String("inflow_vault_pda=") + bytesToBase58(inflowVault, 32) + "\n";
ShinePdaUserState nextState = currentState; ShinePdaUserState nextState = currentState;
memcpy(nextState.recoveryKey32, recoveryPub, 32);
memcpy(nextState.rootKey32, rootPub, 32); memcpy(nextState.rootKey32, rootPub, 32);
memcpy(nextState.clientKey32, clientPub, 32); memcpy(nextState.clientKey32, clientPub, 32);
nextState.updatedAtMs = shineNowMs(); nextState.updatedAtMs = shineNowMs();
@ -3075,6 +3305,7 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag
memcpy(rec.sessionPubKey32, homeserverPub, 32); memcpy(rec.sessionPubKey32, homeserverPub, 32);
nextState.sessions.push_back(rec); nextState.sessions.push_back(rec);
} }
saveRegisterDiagCheckpoint("Homeserver session merged", diagDetails);
std::vector<uint8_t> oldUnsigned = serializeUnsignedRecordState(currentState); std::vector<uint8_t> oldUnsigned = serializeUnsignedRecordState(currentState);
uint8_t prevHash32[32] = {}; uint8_t prevHash32[32] = {};
@ -3091,6 +3322,7 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag
} }
diagDetails += String("unsigned_record_len=") + String((unsigned long)newUnsigned.size()) + "\n"; diagDetails += String("unsigned_record_len=") + String((unsigned long)newUnsigned.size()) + "\n";
diagDetails += String("unsigned_record_hash=") + bytesToHexString(unsignedHash, 32) + "\n"; diagDetails += String("unsigned_record_hash=") + bytesToHexString(unsignedHash, 32) + "\n";
saveRegisterDiagCheckpoint("Unsigned update built", diagDetails);
std::vector<uint8_t> lastBlockState = buildLastBlockStateBytes( std::vector<uint8_t> lastBlockState = buildLastBlockStateBytes(
cleanLogin, cleanLogin,
@ -3118,6 +3350,7 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag
return failWithDiag(messageOut); return failWithDiag(messageOut);
} }
diagDetails += String("recent_blockhash=") + recentBlockhash58 + "\n"; diagDetails += String("recent_blockhash=") + recentBlockhash58 + "\n";
saveRegisterDiagCheckpoint("Recent blockhash loaded", diagDetails);
std::vector<uint8_t> message = buildUpdateLegacyMessage( std::vector<uint8_t> message = buildUpdateLegacyMessage(
recentBlockhash, recentBlockhash,
@ -3135,6 +3368,7 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag
String txBase64 = encodeTransactionBase64(txSignature, message); String txBase64 = encodeTransactionBase64(txSignature, message);
String signatureB58 = bytesToBase58(txSignature, 64); String signatureB58 = bytesToBase58(txSignature, 64);
diagDetails += String("tx_signature=") + signatureB58 + "\n"; diagDetails += String("tx_signature=") + signatureB58 + "\n";
saveRegisterDiagCheckpoint("Signed update transaction", diagDetails);
String payload; String payload;
if (!rpcCallSolana("sendTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"preflightCommitment\":\"confirmed\"}]", payload)) { if (!rpcCallSolana("sendTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"preflightCommitment\":\"confirmed\"}]", payload)) {
@ -3282,6 +3516,13 @@ static bool parseShineUserPdaBytes(const std::vector<uint8_t> &bytes, ShinePdaUs
errorOut = "Bad PDA block header"; errorOut = "Bad PDA block header";
return false; return false;
} }
if (blockType == kBlockTypeRecoveryKey) {
if (!readBytes(outState.recoveryKey32, 32)) {
errorOut = "Bad recovery key block";
return false;
}
continue;
}
if (blockType == kBlockTypeRootKey) { if (blockType == kBlockTypeRootKey) {
if (!readBytes(outState.rootKey32, 32)) { if (!readBytes(outState.rootKey32, 32)) {
errorOut = "Bad root key block"; errorOut = "Bad root key block";
@ -3492,7 +3733,7 @@ static void refreshAccountPdaStatus() {
if (gLoginValue.isEmpty() || !gSecretConfigured) { if (gLoginValue.isEmpty() || !gSecretConfigured) {
gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN;
gAccountPdaStatusMessage = "account not configured"; gAccountPdaStatusMessage = "account not configured";
gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; gShineStatusLine = String("SHiNE: ") + shineServerDisplayLabel() + " account not configured";
clearShineSessionState(false); clearShineSessionState(false);
return; return;
} }
@ -3502,7 +3743,7 @@ static void refreshAccountPdaStatus() {
if (!readShineUserPda(gLoginValue, pdaState, error)) { if (!readShineUserPda(gLoginValue, pdaState, error)) {
gAccountPdaStatus = ACCOUNT_PDA_MISMATCH; gAccountPdaStatus = ACCOUNT_PDA_MISMATCH;
gAccountPdaStatusMessage = error.isEmpty() ? "solana check failed" : error; gAccountPdaStatusMessage = error.isEmpty() ? "solana check failed" : error;
gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " unavailable"; gShineStatusLine = String("SHiNE: ") + shineServerDisplayLabel() + " unavailable";
clearShineSessionState(false); clearShineSessionState(false);
if (error == "Solana RPC unavailable") { if (error == "Solana RPC unavailable") {
gAccountCheckPending = true; gAccountCheckPending = true;
@ -3514,26 +3755,30 @@ static void refreshAccountPdaStatus() {
gAccountPdaStatus = ACCOUNT_PDA_NOT_FOUND; gAccountPdaStatus = ACCOUNT_PDA_NOT_FOUND;
gAccountPdaStatusMessage = "user not found"; gAccountPdaStatusMessage = "user not found";
gShowRegisterAccountButton = true; gShowRegisterAccountButton = true;
gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; gShineStatusLine = String("SHiNE: ") + shineServerDisplayLabel() + " account not configured";
clearShineSessionState(false); clearShineSessionState(false);
return; return;
} }
uint8_t recoveryPub[32] = {};
uint8_t rootPub[32] = {}; uint8_t rootPub[32] = {};
uint8_t clientPub[32] = {}; uint8_t clientPub[32] = {};
uint8_t blockchainPub[32] = {}; uint8_t blockchainPub[32] = {};
if (!base58ToFixed32(gRootPubB58, rootPub) if (!base58ToFixed32(gRecoveryPubB58, recoveryPub)
|| !base58ToFixed32(gRootPubB58, rootPub)
|| !base58ToFixed32(gDevicePubB58, clientPub) || !base58ToFixed32(gDevicePubB58, clientPub)
|| !base58ToFixed32(gBlockchainPubB58, blockchainPub)) { || !base58ToFixed32(gBlockchainPubB58, blockchainPub)) {
gAccountPdaStatus = ACCOUNT_PDA_MISMATCH; gAccountPdaStatus = ACCOUNT_PDA_MISMATCH;
gAccountPdaStatusMessage = "local keys invalid"; gAccountPdaStatusMessage = "local keys invalid";
gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; gShineStatusLine = String("SHiNE: ") + shineServerDisplayLabel() + " account not configured";
clearShineSessionState(false); clearShineSessionState(false);
return; return;
} }
String mismatch; String mismatch;
if (memcmp(rootPub, pdaState.rootKey32, 32) != 0) { if (memcmp(recoveryPub, pdaState.recoveryKey32, 32) != 0) {
mismatch = "recovery key mismatch";
} else if (memcmp(rootPub, pdaState.rootKey32, 32) != 0) {
mismatch = "root key mismatch"; mismatch = "root key mismatch";
} else if (memcmp(blockchainPub, pdaState.blockchainKey32, 32) != 0) { } else if (memcmp(blockchainPub, pdaState.blockchainKey32, 32) != 0) {
mismatch = "blockchain key mismatch"; mismatch = "blockchain key mismatch";
@ -3578,11 +3823,14 @@ static void refreshAccountPdaStatus() {
gHomeserverPdaActionReason = mismatch; gHomeserverPdaActionReason = mismatch;
gHomeserverPdaCanFix = true; gHomeserverPdaCanFix = true;
} }
gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; gShineStatusLine = String("SHiNE: ") + shineServerDisplayLabel() + " account not configured";
clearShineSessionState(false); clearShineSessionState(false);
return; return;
} }
gCachedAccountPdaState = pdaState;
gCachedAccountPdaLogin = normalizeLoginValue(gLoginValue);
gCachedAccountPdaValid = true;
gAccountPdaStatus = ACCOUNT_PDA_OK; gAccountPdaStatus = ACCOUNT_PDA_OK;
gAccountPdaStatusMessage = "ok"; gAccountPdaStatusMessage = "ok";
} }
@ -4219,8 +4467,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
diagDetails += String("uptime_ms=") + String(millis()) + "\n"; diagDetails += String("uptime_ms=") + String(millis()) + "\n";
diagDetails += String("login=") + gLoginValue + "\n"; diagDetails += String("login=") + gLoginValue + "\n";
diagDetails += String("homeserver=") + gHomeserverValue + "\n"; diagDetails += String("homeserver=") + gHomeserverValue + "\n";
diagDetails += String("server_url=") + gShineServerUrl + "\n"; diagDetails += String("server_login=") + currentShineServerLoginSource() + "\n";
diagDetails += String("ws_url=") + shineWsUrl() + "\n";
diagDetails += String("pda_status=") + gAccountPdaStatusMessage + "\n"; diagDetails += String("pda_status=") + gAccountPdaStatusMessage + "\n";
if (WiFi.status() != WL_CONNECTED) { if (WiFi.status() != WL_CONNECTED) {
diagDetails += "wifi=disconnected\n"; diagDetails += "wifi=disconnected\n";
@ -4233,6 +4480,12 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
if (gAccountPdaStatus != ACCOUNT_PDA_OK) { if (gAccountPdaStatus != ACCOUNT_PDA_OK) {
return failWithDiag("account not ready"); return failWithDiag("account not ready");
} }
if (!ensureCurrentShineServerUrl(errorOut)) {
diagDetails += String("server_resolve_error=") + errorOut + "\n";
return failWithDiag(errorOut);
}
diagDetails += String("server_url=") + gShineServerUrl + "\n";
diagDetails += String("ws_url=") + shineWsUrl() + "\n";
String wsUrl = shineWsUrl(); String wsUrl = shineWsUrl();
if (wsUrl.isEmpty()) { if (wsUrl.isEmpty()) {
@ -4394,7 +4647,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
} }
static void manageShineConnection() { static void manageShineConnection() {
String serverLabel = gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl; String serverLabel = shineServerDisplayLabel();
if (gLoginValue.isEmpty() || !gSecretConfigured || gHomeserverValue.isEmpty()) { if (gLoginValue.isEmpty() || !gSecretConfigured || gHomeserverValue.isEmpty()) {
gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured"; gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured";
clearShineSessionState(false); clearShineSessionState(false);
@ -4424,6 +4677,13 @@ static void manageShineConnection() {
} }
gLastShineAttemptMs = now; gLastShineAttemptMs = now;
String error; String error;
if (!ensureCurrentShineServerUrl(error)) {
gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable";
clearShineSessionState(false);
gShineReconnectDelayMs = min(gShineReconnectDelayMs + SHINE_RECONNECT_MIN_MS, (unsigned long)SHINE_RECONNECT_MAX_MS);
return;
}
serverLabel = shineServerDisplayLabel();
if (ensureShineSessionAuthenticated(error)) { if (ensureShineSessionAuthenticated(error)) {
gShineStatusLine = String("SHiNE: ") + serverLabel + " connected"; gShineStatusLine = String("SHiNE: ") + serverLabel + " connected";
gLastShinePingMs = now; gLastShinePingMs = now;
@ -4507,7 +4767,18 @@ static void loadPrefs() {
upsertKnownWifi(gWifiSavedSsid, gWifiSavedPassword); upsertKnownWifi(gWifiSavedSsid, gWifiSavedPassword);
} }
gSolanaRpcUrl = gPrefs.getString("solana_rpc", "https://api.devnet.solana.com"); gSolanaRpcUrl = gPrefs.getString("solana_rpc", "https://api.devnet.solana.com");
gShineServerUrl = gPrefs.getString("shine_server", "https://shineup.me"); String storedShineServerLogin = normalizeLoginValue(gPrefs.getString("shine_server_login", ""));
if (!isValidShineServerLoginValue(storedShineServerLogin)) {
String legacyShineServer = normalizeLoginValue(gPrefs.getString("shine_server", ""));
if (isValidShineServerLoginValue(legacyShineServer)) {
storedShineServerLogin = legacyShineServer;
} else {
storedShineServerLogin = kDefaultShineServerLogin;
}
}
gShineServerLogin = storedShineServerLogin;
gShineServerUrl = "";
gResolvedShineServerLogin = "";
gLoginValue = gPrefs.getString("login", ""); gLoginValue = gPrefs.getString("login", "");
gHomeserverValue = gPrefs.getString("homeserver", "homeserver1"); gHomeserverValue = gPrefs.getString("homeserver", "homeserver1");
String walletTypeStored = gPrefs.getString("wallet_type", "client.key"); String walletTypeStored = gPrefs.getString("wallet_type", "client.key");
@ -4576,7 +4847,8 @@ static void saveWifiPrefs() {
static void saveServerPrefs() { static void saveServerPrefs() {
gPrefs.putString("solana_rpc", gSolanaRpcUrl); gPrefs.putString("solana_rpc", gSolanaRpcUrl);
gPrefs.putString("shine_server", gShineServerUrl); gPrefs.putString("shine_server_login", gShineServerLogin);
gPrefs.remove("shine_server");
} }
static void saveAccountPrefs() { static void saveAccountPrefs() {
@ -4730,6 +5002,10 @@ static void saveRegisterDiag(const String &status, const String &summary, const
saveRegisterDiagDetailsToPrefs(details); saveRegisterDiagDetailsToPrefs(details);
} }
static void saveRegisterDiagCheckpoint(const String &summary, const String &details) {
saveRegisterDiag("progress", summary, details);
}
static void clearRegisterDiag() { static void clearRegisterDiag() {
gLastRegisterDiagStatus = "none"; gLastRegisterDiagStatus = "none";
gLastRegisterDiagSummary = ""; gLastRegisterDiagSummary = "";
@ -5094,13 +5370,18 @@ static void applyEditorValue() {
} }
if (gEditContext == EDIT_CONTEXT_SHINE_SERVER) { if (gEditContext == EDIT_CONTEXT_SHINE_SERVER) {
gShineServerUrl = value; gShineServerLogin = normalizeLoginValue(value);
if (!isValidShineServerLoginValue(gShineServerLogin)) {
gShineServerLogin = kDefaultShineServerLogin;
}
gShineServerUrl = "";
gResolvedShineServerLogin = "";
saveServerPrefs(); saveServerPrefs();
gServerStatusMessage = "Shine server saved"; gServerStatusMessage = "SHiNE server login saved";
clearShineSessionState(false); clearShineSessionState(false);
gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS; gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS;
gLastShineAttemptMs = 0; gLastShineAttemptMs = 0;
gShineStatusLine = "SHiNE: reconnect pending"; gShineStatusLine = String("SHiNE: ") + gShineServerLogin + " reconnect pending";
showScreen(SCREEN_SERVER); showScreen(SCREEN_SERVER);
return; return;
} }
@ -5454,9 +5735,9 @@ static void actionButtonCb(lv_event_t *event) {
case ACTION_SERVER_EDIT_SHINE: case ACTION_SERVER_EDIT_SHINE:
openEditor(EDIT_CONTEXT_SHINE_SERVER, openEditor(EDIT_CONTEXT_SHINE_SERVER,
SCREEN_SERVER, SCREEN_SERVER,
"EDIT SHINE HOST", "EDIT SHINE SERVER LOGIN",
"", "",
gShineServerUrl, gShineServerLogin,
false); false);
break; break;
case ACTION_ACCOUNT_EDIT_LOGIN: case ACTION_ACCOUNT_EDIT_LOGIN:
@ -6201,8 +6482,12 @@ static void drawServerScreen() {
showMessageAt(gServerStatusMessage, 56); showMessageAt(gServerStatusMessage, 56);
showMessageAt(String("Solana: ") + gSolanaRpcUrl, 96); showMessageAt(String("Solana: ") + gSolanaRpcUrl, 96);
makeButton("SOLANA RPC", 22, 146, 436, 84, 0x355C7D, ACTION_SERVER_EDIT_SOLANA, &lv_font_montserrat_24); makeButton("SOLANA RPC", 22, 146, 436, 84, 0x355C7D, ACTION_SERVER_EDIT_SOLANA, &lv_font_montserrat_24);
showMessageAt(String("Shine: ") + gShineServerUrl, 248); showMessageAt(String("SHiNE: ") + shineServerDisplayLabel(), 248);
makeButton("SHINE SERVER", 22, 298, 436, 84, 0x355C7D, ACTION_SERVER_EDIT_SHINE, &lv_font_montserrat_24); if (gUserPdaAddress.isEmpty()) {
makeButton("SHiNE SERVER LOGIN", 22, 298, 436, 84, 0x355C7D, ACTION_SERVER_EDIT_SHINE, &lv_font_montserrat_22);
} else {
makeBody("SHiNE server login is read from PDA.", 312, 360);
}
makeBody("Swipe right to return to Settings.", 396, 420); makeBody("Swipe right to return to Settings.", 396, 420);
makeVersionTag(); makeVersionTag();
} }
@ -6243,14 +6528,14 @@ static void drawAccountHomeserverScreen() {
static void drawAccountSecretScreen() { static void drawAccountSecretScreen() {
setRootStyle(); setRootStyle();
makeTitle("SECRET", 18, &lv_font_montserrat_24); makeTitle("MASTER SECRET", 18, &lv_font_montserrat_24);
showMessageAt(gAccountStatusMessage, 56); showMessageAt(gAccountStatusMessage, 56);
makeButton(gSecretConfigured ? "SHOW SECRET" : "SECRET NOT SET", makeButton(gSecretConfigured ? "SHOW MASTER SECRET" : "MASTER SECRET NOT SET",
22, 118, 436, 84, gSecretConfigured ? 0x2A6F97 : 0x4A5560, 22, 118, 436, 84, gSecretConfigured ? 0x2A6F97 : 0x4A5560,
gSecretConfigured ? ACTION_SECRET_SHOW : ACTION_NONE, &lv_font_montserrat_22); gSecretConfigured ? ACTION_SECRET_SHOW : ACTION_NONE, &lv_font_montserrat_22);
makeButton("ENTER SECRET MANUALLY (NOT RECOMMENDED)", makeButton("ENTER MASTER SECRET MANUALLY (NOT RECOMMENDED)",
22, 222, 436, 84, 0x355C7D, ACTION_SECRET_MANUAL, &lv_font_montserrat_18); 22, 222, 436, 84, 0x355C7D, ACTION_SECRET_MANUAL, &lv_font_montserrat_18);
makeButton("GENERATE SECRET", makeButton("GENERATE MASTER SECRET",
22, 326, 436, 84, 0x2A9D8F, ACTION_SECRET_GENERATE, &lv_font_montserrat_22); 22, 326, 436, 84, 0x2A9D8F, ACTION_SECRET_GENERATE, &lv_font_montserrat_22);
makeBody("Swipe right to return to Account.", 420, 420); makeBody("Swipe right to return to Account.", 420, 420);
makeVersionTag(); makeVersionTag();
@ -6258,7 +6543,7 @@ static void drawAccountSecretScreen() {
static void drawSecretShowScreen() { static void drawSecretShowScreen() {
setRootStyle(); setRootStyle();
makeTitle("SECRET", 18, &lv_font_montserrat_24); makeTitle("MASTER SECRET", 18, &lv_font_montserrat_24);
if (gSecretConfigured) { if (gSecretConfigured) {
lv_obj_t *panel = lv_obj_create(gRoot); lv_obj_t *panel = lv_obj_create(gRoot);
lv_obj_set_size(panel, 440, 320); lv_obj_set_size(panel, 440, 320);
@ -6268,25 +6553,25 @@ static void drawSecretShowScreen() {
lv_obj_set_style_border_width(panel, 1, 0); lv_obj_set_style_border_width(panel, 1, 0);
lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0); lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0);
lv_obj_set_style_radius(panel, 14, 0); lv_obj_set_style_radius(panel, 14, 0);
lv_obj_set_style_pad_all(panel, 14, 0); lv_obj_set_style_pad_top(panel, 14, 0);
lv_obj_set_style_pad_bottom(panel, 14, 0);
lv_obj_set_style_pad_left(panel, 30, 0);
lv_obj_set_style_pad_right(panel, 14, 0);
lv_obj_set_style_pad_row(panel, 8, 0); lv_obj_set_style_pad_row(panel, 8, 0);
lv_obj_set_scroll_dir(panel, LV_DIR_VER); lv_obj_set_scroll_dir(panel, LV_DIR_VER);
lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_ACTIVE); lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_ACTIVE);
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_add_event_cb(panel, secretScrollEventCb, LV_EVENT_SCROLL_BEGIN, nullptr);
lv_obj_add_event_cb(panel, secretScrollEventCb, LV_EVENT_SCROLL_END, nullptr);
lv_obj_add_event_cb(panel, secretScrollEventCb, LV_EVENT_DELETE, nullptr);
auto addKeyBlock = [&](const String &title, const String &formula, const String &value) { auto addKeyBlock = [&](const String &title, const String &value) {
lv_obj_t *titleLabel = lv_label_create(panel); lv_obj_t *titleLabel = lv_label_create(panel);
lv_label_set_text(titleLabel, title.c_str()); lv_label_set_text(titleLabel, title.c_str());
lv_obj_set_width(titleLabel, 400); lv_obj_set_width(titleLabel, 400);
lv_obj_set_style_text_font(titleLabel, &lv_font_montserrat_18, 0); lv_obj_set_style_text_font(titleLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(titleLabel, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_color(titleLabel, lv_color_hex(0xA7D8FF), 0);
lv_obj_t *formulaLabel = lv_label_create(panel);
lv_label_set_text(formulaLabel, formula.c_str());
lv_obj_set_width(formulaLabel, 400);
lv_obj_set_style_text_font(formulaLabel, &lv_font_montserrat_12, 0);
lv_obj_set_style_text_color(formulaLabel, lv_color_hex(0x8FA4B8), 0);
lv_obj_t *valueLabel = lv_label_create(panel); lv_obj_t *valueLabel = lv_label_create(panel);
lv_label_set_text(valueLabel, value.c_str()); lv_label_set_text(valueLabel, value.c_str());
@ -6296,15 +6581,17 @@ static void drawSecretShowScreen() {
lv_obj_set_style_text_color(valueLabel, lv_color_hex(0xD9E1EA), 0); lv_obj_set_style_text_color(valueLabel, lv_color_hex(0xD9E1EA), 0);
}; };
addKeyBlock("Secret (base58)", "master secret", gSecretBase58); addKeyBlock("Master Secret", gSecretBase58);
addKeyBlock("Root key (base58)", "pub from sha256(base64(secret)|root.key)", gRootPubB58); addKeyBlock("Recovery key", gRecoveryPubB58);
addKeyBlock("Root key priv (base58)", "sha256(base64(secret)|root.key)", gRootPrivB58); addKeyBlock("Recovery key priv", gRecoveryPrivB58);
addKeyBlock("Blockchain key (base58)", "pub from sha256(base64(secret)|bch.key)", gBlockchainPubB58); addKeyBlock("Root key", gRootPubB58);
addKeyBlock("Blockchain key priv (base58)", "sha256(base64(secret)|bch.key)", gBlockchainPrivB58); addKeyBlock("Root key priv", gRootPrivB58);
addKeyBlock("Client key (base58)", "pub from sha256(base64(secret)|client.key)", gDevicePubB58); addKeyBlock("Blockchain key", gBlockchainPubB58);
addKeyBlock("Client key priv (base58)", "sha256(base64(secret)|client.key)", gDevicePrivB58); addKeyBlock("Blockchain key priv", gBlockchainPrivB58);
addKeyBlock("Homeserver key (base58)", String("pub from sha256(base64(secret)|") + homeserverKeySuffix() + ")", gHomeserverPubB58); addKeyBlock("Client key", gDevicePubB58);
addKeyBlock("Homeserver key priv (base58)", String("sha256(base64(secret)|") + homeserverKeySuffix() + ")", gHomeserverPrivB58); addKeyBlock("Client key priv", gDevicePrivB58);
addKeyBlock("Homeserver key", gHomeserverPubB58);
addKeyBlock("Homeserver key priv", gHomeserverPrivB58);
} else { } else {
showMessageAt("Secret not set", 96); showMessageAt("Secret not set", 96);
} }

View File

@ -1,7 +1,6 @@
#include "shine_secret_generation.h" #include "shine_secret_generation.h"
#include <SD_MMC.h> #include <SD_MMC.h>
#include <Preferences.h>
#include <mbedtls/sha256.h> #include <mbedtls/sha256.h>
#include <mbedtls/base64.h> #include <mbedtls/base64.h>
#include <string.h> #include <string.h>
@ -80,6 +79,19 @@ static void setMessage(const char *message) {
snprintf(gMessage, sizeof(gMessage), "%s", message ? message : ""); snprintf(gMessage, sizeof(gMessage), "%s", message ? message : "");
} }
static void sha256calc(const uint8_t *in, size_t len, uint8_t *out32);
static void finishSecretFromBytes(const uint8_t secret32[32], const char *message) {
memcpy(gSecret, secret32, 32);
shineSecretBase58Encode(gSecret, 32, gSecretB58, sizeof(gSecretB58));
gDone = true;
gRunning = false;
gError = false;
gInitDone = false;
gDoneBlocks = TOTAL_FILLS;
setMessage(message ? message : "Secret generated");
}
static void b2_compress(B2State *S, const uint8_t *blk) { static void b2_compress(B2State *S, const uint8_t *blk) {
uint64_t m[16], v[16]; uint64_t m[16], v[16];
for (int i = 0; i < 16; i++) m[i] = ((const uint64_t *)blk)[i]; for (int i = 0; i < 16; i++) m[i] = ((const uint64_t *)blk)[i];
@ -449,7 +461,6 @@ bool shineSecretInitSd(String &error) {
bool shineSecretStart(const char *normalizedLogin, const char *password, String &error) { bool shineSecretStart(const char *normalizedLogin, const char *password, String &error) {
error = ""; error = "";
if (!shineSecretInitSd(error)) return false;
if (!normalizedLogin || !*normalizedLogin) { if (!normalizedLogin || !*normalizedLogin) {
error = "login not set"; error = "login not set";
return false; return false;
@ -463,6 +474,8 @@ bool shineSecretStart(const char *normalizedLogin, const char *password, String
return false; return false;
} }
if (!shineSecretInitSd(error)) return false;
if (gSdFile) gSdFile.close(); if (gSdFile) gSdFile.close();
SD_MMC.remove(SD_MEM_FILE); SD_MMC.remove(SD_MEM_FILE);
gSdFile = SD_MMC.open(SD_MEM_FILE, "w+"); gSdFile = SD_MMC.open(SD_MEM_FILE, "w+");

View File

@ -224,12 +224,14 @@ static int16_t gTouchLastY = 0;
struct DerivedKeyState { struct DerivedKeyState {
bool ready; bool ready;
uint8_t masterSecret[32]; uint8_t masterSecret[32];
uint8_t recoveryPub[32];
uint8_t recoverySk[64];
uint8_t rootPub[32]; uint8_t rootPub[32];
uint8_t rootSk[64]; uint8_t rootSk[64];
uint8_t blockchainPub[32]; uint8_t blockchainPub[32];
uint8_t blockchainSk[64]; uint8_t blockchainSk[64];
uint8_t clientPub[32]; uint8_t clientPub[32];
uint8_t deviceSk[64]; uint8_t clientSk[64];
}; };
static DerivedKeyState gDerivedKeys = {}; static DerivedKeyState gDerivedKeys = {};
@ -782,19 +784,20 @@ static void pushFixed(std::vector<uint8_t> &out, const uint8_t *data, size_t len
static bool deriveKeysFromMasterSecret(const uint8_t masterSecret[32]) { static bool deriveKeysFromMasterSecret(const uint8_t masterSecret[32]) {
memset(&gDerivedKeys, 0, sizeof(gDerivedKeys)); memset(&gDerivedKeys, 0, sizeof(gDerivedKeys));
memcpy(gDerivedKeys.masterSecret, masterSecret, 32); memcpy(gDerivedKeys.masterSecret, masterSecret, 32);
String secretB64 = base64Encode(masterSecret, 32); const char *prefix = "SHiNE-key";
if (secretB64.length() == 0) { const char *suffixes[4] = {"recovery.key", "root.key", "blockchain.key", "client.key"};
return false; uint8_t *pubs[4] = {gDerivedKeys.recoveryPub, gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.clientPub};
} uint8_t *sks[4] = {gDerivedKeys.recoverySk, gDerivedKeys.rootSk, gDerivedKeys.blockchainSk, gDerivedKeys.clientSk};
const char *suffixes[3] = {"root.key", "blockchain.key", "client.key"}; for (int i = 0; i < 4; i++) {
uint8_t *pubs[3] = {gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.clientPub}; std::vector<uint8_t> material;
uint8_t *sks[3] = {gDerivedKeys.rootSk, gDerivedKeys.blockchainSk, gDerivedKeys.deviceSk}; material.reserve(strlen(prefix) + 1 + 32 + 1 + strlen(suffixes[i]));
for (int i = 0; i < 3; i++) { material.insert(material.end(), prefix, prefix + strlen(prefix));
String material = secretB64 + "|" + suffixes[i]; material.push_back(0);
material.insert(material.end(), masterSecret, masterSecret + 32);
material.push_back(0);
material.insert(material.end(), suffixes[i], suffixes[i] + strlen(suffixes[i]));
uint8_t seed[32]; uint8_t seed[32];
if (!sha256String(material, seed)) { sha256Raw(material.data(), material.size(), seed);
return false;
}
if (crypto_sign_seed_keypair(pubs[i], sks[i], seed) != 0) { if (crypto_sign_seed_keypair(pubs[i], sks[i], seed) != 0) {
return false; return false;
} }
@ -1277,7 +1280,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
edBchData, edBchData,
createData); createData);
uint8_t txSignature[64]; uint8_t txSignature[64];
if (!signMessageEd25519(message, gDerivedKeys.deviceSk, txSignature)) { if (!signMessageEd25519(message, gDerivedKeys.clientSk, txSignature)) {
messageOut = "Не удалось подписать Solana-транзакцию"; messageOut = "Не удалось подписать Solana-транзакцию";
return false; return false;
} }

View File

@ -150,12 +150,12 @@
- статусное сообщение; - статусное сообщение;
- текущий `Solana RPC` адрес; - текущий `Solana RPC` адрес;
- кнопку `SOLANA RPC`; - кнопку `SOLANA RPC`;
- текущий `Shine server` адрес; - текущий `SHiNE server login` или уже резолвленный адрес;
- кнопку `SHINE SERVER`. - кнопку `SHiNE SERVER LOGIN`, если обычный `user PDA` ещё не зарегистрирован.
Значения по умолчанию: Значения по умолчанию:
- Solana RPC: `https://api.devnet.solana.com` - Solana RPC: `https://api.devnet.solana.com`
- Shine server: `https://shineup.me` - SHiNE server login: `shineupme`
Нажатие на любую из двух кнопок открывает `TEXT_EDIT_SCREEN`. Нажатие на любую из двух кнопок открывает `TEXT_EDIT_SCREEN`.
@ -229,7 +229,7 @@
Используется для: Используется для:
- пароля Wi-Fi; - пароля Wi-Fi;
- Solana RPC; - Solana RPC;
- Shine server. - SHiNE server login.
Показывает: Показывает:
- заголовок; - заголовок;
@ -291,7 +291,7 @@
Используется `Preferences` (NVS памяти ESP32): Используется `Preferences` (NVS памяти ESP32):
- `solana_rpc` - `solana_rpc`
- `shine_server` - `shine_server_login`
## Хранение аккаунта ## Хранение аккаунта

View File

@ -65,12 +65,13 @@
- `user pda address`; - `user pda address`;
- `registration signature`; - `registration signature`;
- `balance`; - `balance`;
- `server api url`; - `server login` для первичной привязки;
- `server rpc url`; - `resolved server api url` / `rpc url` / `ws url` после чтения PDA сервера;
- `server ws url`;
- флаги: - флаги:
`wifiReady`, `serversReady`, `secretReady`, `registered`, `online`. `wifiReady`, `serversReady`, `secretReady`, `registered`, `online`.
Для первой регистрации обычного `user PDA` устройство берёт `createdAtMs` / `updatedAtMs` из NTP прямо перед отправкой транзакции в Solana. Дальше в `user PDA` сохраняется `accessServers`, где по умолчанию лежит `shineupme`.
## Правило серверной сессии SHiNE ## Правило серверной сессии SHiNE
При подключении к серверу `SHiNE` устройство должно авторизовываться как homeserver-сеанс: При подключении к серверу `SHiNE` устройство должно авторизовываться как homeserver-сеанс:
@ -86,7 +87,7 @@
Кнопка регистрации доступна только если одновременно выполнены условия: Кнопка регистрации доступна только если одновременно выполнены условия:
1. настроен и подтверждён `Wi-Fi`; 1. настроен и подтверждён `Wi-Fi`;
2. заполнены и подтверждены серверные адреса; 2. задан и подтверждён `SHiNE server login`;
3. задан логин; 3. задан логин;
4. сгенерирован или введён секрет; 4. сгенерирован или введён секрет;
5. баланс кошелька не меньше `0.20 SOL`; 5. баланс кошелька не меньше `0.20 SOL`;
@ -628,7 +629,7 @@
2. открыть `Подключение -> Wi-Fi`; 2. открыть `Подключение -> Wi-Fi`;
3. ввести `SSID` и пароль, нажать `Проверить`; 3. ввести `SSID` и пароль, нажать `Проверить`;
4. открыть `Подключение -> Серверы`; 4. открыть `Подключение -> Серверы`;
5. проверить или задать серверные адреса; 5. проверить или задать `SHiNE server login` (по умолчанию `shineupme`);
6. открыть `Аккаунт`; 6. открыть `Аккаунт`;
7. ввести логин; 7. ввести логин;
8. задать имя homeserver; 8. задать имя homeserver;
@ -637,14 +638,15 @@
11. при необходимости пополнить баланс; 11. при необходимости пополнить баланс;
12. вернуться на `HOME`; 12. вернуться на `HOME`;
13. нажать `REGISTER ACCOUNT`; 13. нажать `REGISTER ACCOUNT`;
14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1` и при необходимости сообщение о неподключённом `Wi-Fi`; 14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1`, серверный login и при необходимости сообщение о неподключённом `Wi-Fi`;
15. нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`; 15. нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`;
16. после завершения увидеть либо экран успеха с `user_pda` и `tx signature`, либо подробную ошибку; 16. после завершения увидеть либо экран успеха с `user_pda` и `tx signature`, либо подробную ошибку;
17. после успешной регистрации увидеть статус `Homeserver активен`. 17. после успешной регистрации увидеть статус `Homeserver активен`.
Примечание: Примечание:
- устройство реально отправляет `create_user_pda` в `shine_users`, а после подтверждения сохраняет `PDA` и `tx signature`. - устройство реально отправляет `create_user_pda` в `shine_users`, а после подтверждения сохраняет `PDA` и `tx signature`;
- при первой регистрации для обычного `user PDA` не заполняется `serverAddress`, а `accessServers` получает `shineupme` или другой выбранный `SHiNE server login`.
## Сценарий входящего запроса ## Сценарий входящего запроса

View File

@ -167,7 +167,9 @@ public final class SolanaUserPdaImportService {
int blockVer = u8(raw, c++); int blockVer = u8(raw, c++);
if (blockVer != 0) return null; if (blockVer != 0) return null;
if (blockType == 1) { if (blockType == 0) {
c += 32; // recovery_key
} else if (blockType == 1) {
c += 32; c += 32;
} else if (blockType == 2) { } else if (blockType == 2) {
clientKey32 = slice(raw, c, 32); clientKey32 = slice(raw, c, 32);

View File

@ -1,2 +1,2 @@
client.version=1.2.234 client.version=1.2.246
server.version=1.2.220 server.version=1.2.231

View File

@ -0,0 +1,7 @@
// Конфиг по умолчанию для конкретного деплоя UI.
// Перед выкладкой на другой хост можно поменять этот файл без правки логики приложения.
export const defaultServerLogin = 'shineupme';
export const defaultServerAddress = 'shineup.me';
export const defaultServerHttp = `https://${defaultServerAddress}`;
export const defaultServerWs = `wss://${defaultServerAddress}/ws`;

View File

@ -17,6 +17,7 @@ import {
uploadArweaveFile, uploadArweaveFile,
validateAvatarSourceFile, validateAvatarSourceFile,
} from '../services/arweave-file-service.js'; } from '../services/arweave-file-service.js';
import { defaultServerAddress, defaultServerHttp } from '../deploy-config.js';
export const pageMeta = { id: 'developer-settings-view', title: 'Настройки разработчика' }; export const pageMeta = { id: 'developer-settings-view', title: 'Настройки разработчика' };
@ -207,9 +208,9 @@ function showClientUpdateHelp() {
window.alert( window.alert(
'Если UI не обновился:\n\n' 'Если UI не обновился:\n\n'
+ '1) Закройте вкладки с SHiNE.\n' + '1) Закройте вкладки с SHiNE.\n'
+ '2) Откройте chrome://settings/siteData и удалите данные для shineup.me.\n' + `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n`
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n' + '3) Если приложение установлено как PWA — удалите его с устройства.\n'
+ '4) Откройте https://shineup.me заново и выполните вход.\n' + `4) Откройте ${defaultServerHttp} заново и выполните вход.\n`
+ '5) Если всё ещё старая версия — откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.' + '5) Если всё ещё старая версия — откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
); );
} }

View File

@ -100,6 +100,7 @@ export function render({ navigate }) {
onInput: (index, value) => { onInput: (index, value) => {
passwordWords[index] = value; passwordWords[index] = value;
syncDraftState(); syncDraftState();
updateWordsPreview();
}, },
}); });
@ -165,7 +166,10 @@ export function render({ navigate }) {
syncDraftState(); syncDraftState();
loginInput.addEventListener('input', syncDraftState); loginInput.addEventListener('input', syncDraftState);
passwordInput.addEventListener('input', syncDraftState); passwordInput.addEventListener('input', () => {
syncDraftState();
updateWordsPreview();
});
passwordModeCheckbox.addEventListener('change', () => { passwordModeCheckbox.addEventListener('change', () => {
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single'; const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
@ -181,6 +185,7 @@ export function render({ navigate }) {
} }
passwordMode = nextMode; passwordMode = nextMode;
updatePasswordModeVisibility(); updatePasswordModeVisibility();
updateWordsPreview();
syncDraftState(); syncDraftState();
}); });

View File

@ -14,6 +14,7 @@ import {
PASSWORD_WORDS_COUNT, PASSWORD_WORDS_COUNT,
} from '../services/password-words.js'; } from '../services/password-words.js';
import { openRegistrationFaq } from './registration-faq-view.js'; import { openRegistrationFaq } from './registration-faq-view.js';
import { defaultServerHttp, defaultServerLogin } from '../deploy-config.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false }; export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
@ -100,6 +101,7 @@ export function render({ navigate }) {
onInput: (index, value) => { onInput: (index, value) => {
passwordWords[index] = value; passwordWords[index] = value;
syncDraftState(); syncDraftState();
updateWordsPreview();
}, },
}); });
@ -123,8 +125,8 @@ export function render({ navigate }) {
serverNotice.className = 'card stack'; serverNotice.className = 'card stack';
serverNotice.innerHTML = ` serverNotice.innerHTML = `
<p class="field-label">Первый сервер SHiNE</p> <p class="field-label">Первый сервер SHiNE</p>
<p class="meta-muted">Сейчас вашим первым и основным сервером будет серверный аккаунт <strong>${state.entrySettings.shineServerLogin || 'shineupme'}</strong>.</p> <p class="meta-muted">Сейчас вашим первым и основным сервером будет серверный аккаунт <strong>${state.entrySettings.shineServerLogin || defaultServerLogin}</strong>.</p>
<p class="meta-muted">Текущий адрес этого сервера: <strong>${state.entrySettings.shineServerHttp || 'https://shineup.me'}</strong>.</p> <p class="meta-muted">Текущий адрес этого сервера: <strong>${state.entrySettings.shineServerHttp || defaultServerHttp}</strong>.</p>
<p class="meta-muted">Это первый сервер, на который вам будут писать и звонить после регистрации. Позже его можно будет сменить, а в будущем использовать и несколько серверов сразу.</p> <p class="meta-muted">Это первый сервер, на который вам будут писать и звонить после регистрации. Позже его можно будет сменить, а в будущем использовать и несколько серверов сразу.</p>
`; `;
@ -299,6 +301,7 @@ export function render({ navigate }) {
passwordInput.addEventListener('input', () => { passwordInput.addEventListener('input', () => {
syncDraftState(); syncDraftState();
updateWordsPreview();
}); });
passwordModeCheckbox.addEventListener('change', () => { passwordModeCheckbox.addEventListener('change', () => {
@ -315,6 +318,7 @@ export function render({ navigate }) {
} }
passwordMode = nextMode; passwordMode = nextMode;
updatePasswordModeVisibility(); updatePasswordModeVisibility();
updateWordsPreview();
syncDraftState(); syncDraftState();
}); });

View File

@ -16,6 +16,7 @@ import {
isUserAlreadyExistsSolanaError, isUserAlreadyExistsSolanaError,
registerUserOnSolana, registerUserOnSolana,
} from '../services/solana-register-service.js'; } from '../services/solana-register-service.js';
import { defaultServerLogin } from '../deploy-config.js';
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false }; export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
const MIN_REQUIRED_SOL = 0.01; const MIN_REQUIRED_SOL = 0.01;
@ -194,7 +195,7 @@ export function render({ navigate }) {
login: state.registrationDraft.login, login: state.registrationDraft.login,
keyBundle, keyBundle,
solanaEndpoint: state.entrySettings.solanaServer, solanaEndpoint: state.entrySettings.solanaServer,
accessServers: [state.entrySettings.shineServerLogin || 'shineupme'], accessServers: [state.entrySettings.shineServerLogin || defaultServerLogin],
}); });
} catch (solanaError) { } catch (solanaError) {
const solanaMsg = formatSolanaErrorDetails(solanaError); const solanaMsg = formatSolanaErrorDetails(solanaError);

View File

@ -27,6 +27,7 @@ import {
saveEncryptedUserSecrets, saveEncryptedUserSecrets,
saveSessionMaterial, saveSessionMaterial,
} from './key-vault.js'; } from './key-vault.js';
import { defaultServerWs } from '../deploy-config.js';
const BCH_SUFFIX = '001'; const BCH_SUFFIX = '001';
const ZERO64 = '0'.repeat(64); const ZERO64 = '0'.repeat(64);
@ -73,7 +74,7 @@ const CONNECTION_SUBTYPES = Object.freeze({
function normalizeServerUrl(url) { function normalizeServerUrl(url) {
const value = (url || '').trim(); const value = (url || '').trim();
if (!value) return 'wss://shineup.me/ws'; if (!value) return defaultServerWs;
if (value.startsWith('ws://') || value.startsWith('wss://')) { if (value.startsWith('ws://') || value.startsWith('wss://')) {
try { try {
const parsed = new URL(value); const parsed = new URL(value);

View File

@ -1,9 +1,15 @@
import { readShineUserPda } from './shine-user-pda-service.js'; import { readShineUserPda } from './shine-user-pda-service.js';
import {
defaultServerAddress,
defaultServerHttp,
defaultServerLogin,
defaultServerWs,
} from '../deploy-config.js';
export const DEFAULT_SHINE_SERVER_LOGIN = 'shineupme'; export const DEFAULT_SHINE_SERVER_LOGIN = defaultServerLogin;
export const DEFAULT_SHINE_SERVER_ADDRESS = 'shineup.me'; export const DEFAULT_SHINE_SERVER_ADDRESS = defaultServerAddress;
export const DEFAULT_SHINE_SERVER_HTTP = `https://${DEFAULT_SHINE_SERVER_ADDRESS}`; export const DEFAULT_SHINE_SERVER_HTTP = defaultServerHttp;
export const DEFAULT_SHINE_SERVER_WS = `wss://${DEFAULT_SHINE_SERVER_ADDRESS}/ws`; export const DEFAULT_SHINE_SERVER_WS = defaultServerWs;
function normalizeHostLike(value) { function normalizeHostLike(value) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();

View File

@ -623,6 +623,38 @@ export async function readShineUserPda({ login, solanaEndpoint }) {
}; };
} }
export async function readShineUserPdaByAddress({ pdaAddress, solanaEndpoint }) {
const address = String(pdaAddress || '').trim();
const endpoint = String(solanaEndpoint || '').trim();
if (!address) throw new Error('Не указан адрес PDA');
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const accountAddress = new solana.PublicKey(address);
const accountInfo = await connection.getAccountInfo(accountAddress, 'confirmed');
if (!accountInfo?.data) throw new Error(`PDA не найдена: ${address}`);
return {
...parseShineUserPda(accountInfo.data),
userPda: accountAddress.toBase58(),
pdaAddress: accountAddress.toBase58(),
endpoint,
};
}
export async function readShineUserPdaByRef({ value, solanaEndpoint }) {
const ref = String(value || '').trim();
if (!ref) throw new Error('Не указан логин или адрес PDA');
try {
const bytes = base58ToBytes(ref);
if (bytes.length === 32) {
return await readShineUserPdaByAddress({ pdaAddress: ref, solanaEndpoint });
}
} catch {
// Если это не адрес, читаем как логин.
}
return readShineUserPda({ login: ref, solanaEndpoint });
}
export async function getShineBlockchainUsage({ login, solanaEndpoint }) { export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
const parsed = await readShineUserPda({ login, solanaEndpoint }); const parsed = await readShineUserPda({ login, solanaEndpoint });
const bch = parsed.blockchain; const bch = parsed.blockchain;
@ -653,6 +685,28 @@ function parseHex32(value) {
return out; return out;
} }
async function attachSolanaLogs(error, connection) {
if (!error || typeof error.getLogs !== 'function' || !connection) {
return error;
}
try {
const logs = await error.getLogs(connection);
if (Array.isArray(logs) && logs.length) {
error.logs = logs;
error.transactionLogs = logs;
error.simulationLogs = logs;
if (!String(error.message || '').includes('Logs:')) {
error.message = `${String(error.message || 'Solana transaction failed')} :: Logs: ${logs.join(' | ')}`;
}
}
} catch {
// Если RPC не вернул логи, оставляем исходную ошибку как есть.
}
return error;
}
async function buildCreateContext({ login, keyBundle, solanaEndpoint }) { async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
const cleanLogin = normalizeLogin(login); const cleanLogin = normalizeLogin(login);
const endpoint = String(solanaEndpoint || '').trim(); const endpoint = String(solanaEndpoint || '').trim();
@ -816,12 +870,17 @@ async function createShineUserPdaOnSolana({
data: ixData, data: ixData,
}); });
const signature = await ctx.solana.sendAndConfirmTransaction( let signature;
ctx.connection, try {
new ctx.solana.Transaction().add(ed25519RootIx, ed25519BchIx, createIx), signature = await ctx.solana.sendAndConfirmTransaction(
[ctx.clientKeypair], ctx.connection,
{ commitment: 'confirmed' }, new ctx.solana.Transaction().add(ed25519RootIx, ed25519BchIx, createIx),
); [ctx.clientKeypair],
{ commitment: 'confirmed' },
);
} catch (error) {
throw await attachSolanaLogs(error, ctx.connection);
}
return { return {
signature, signature,
@ -1030,12 +1089,17 @@ export async function updateShineUserPdaOnSolana({
const computeIx = solana.ComputeBudgetProgram.setComputeUnitLimit({ units: 800_000 }); const computeIx = solana.ComputeBudgetProgram.setComputeUnitLimit({ units: 800_000 });
const heapIx = solana.ComputeBudgetProgram.requestHeapFrame({ bytes: 262_144 }); const heapIx = solana.ComputeBudgetProgram.requestHeapFrame({ bytes: 262_144 });
const signature = await solana.sendAndConfirmTransaction( let signature;
connection, try {
new solana.Transaction().add(computeIx, heapIx, edIxRoot, edIxBch, updateIx), signature = await solana.sendAndConfirmTransaction(
[clientKeypair], connection,
{ commitment: 'confirmed' }, new solana.Transaction().add(computeIx, heapIx, edIxRoot, edIxBch, updateIx),
); [clientKeypair],
{ commitment: 'confirmed' },
);
} catch (error) {
throw await attachSolanaLogs(error, connection);
}
return { return {
signature, signature,

View File

@ -1,10 +1,11 @@
import { captureClientError } from './client-error-reporter.js'; import { captureClientError } from './client-error-reporter.js';
import { defaultServerWs } from '../deploy-config.js';
const DEFAULT_TIMEOUT_MS = 12000; const DEFAULT_TIMEOUT_MS = 12000;
function buildWsUrl(raw) { function buildWsUrl(raw) {
const value = (raw || '').trim(); const value = (raw || '').trim();
if (!value) return 'wss://shineup.me/ws'; if (!value) return defaultServerWs;
if (value.startsWith('/')) { if (value.startsWith('/')) {
const secure = window.location.protocol === 'https:'; const secure = window.location.protocol === 'https:';
const scheme = secure ? 'wss' : 'ws'; const scheme = secure ? 'wss' : 'ws';

View File

@ -14,21 +14,28 @@
<div class="card"> <div class="card">
<h2>Действия</h2> <h2>Действия</h2>
<div style="margin-bottom: 12px;"> <div style="margin-bottom: 12px;">
<a href="server-ui/create-server-pda.html"> <a href="server-ui/create-server-pda.html">
<button class="btn-primary" style="width:100%"> <button class="btn-primary" style="width:100%">
Зарегистрировать серверный аккаунт (создать PDA) Зарегистрировать серверный аккаунт (создать PDA)
</button> </button>
</a> </a>
</div>
<div>
<a href="server-ui/update-server-pda.html">
<button class="btn-secondary" style="width:100%">
Обновить настройки сервера (update PDA)
</button>
</a>
</div>
<div style="margin-top: 12px;">
<a href="server-ui/read-pda.html">
<button class="btn-secondary" style="width:100%">
Просмотреть любой PDA
</button>
</a>
</div>
</div> </div>
<div>
<a href="server-ui/update-server-pda.html">
<button class="btn-secondary" style="width:100%">
Обновить настройки сервера (update PDA)
</button>
</a>
</div>
</div>
<div class="card"> <div class="card">
<h2>Как это работает</h2> <h2>Как это работает</h2>

View File

@ -38,6 +38,7 @@
<div class="nav-links"> <div class="nav-links">
<a href="../server-ui.html">← Назад</a> <a href="../server-ui.html">← Назад</a>
<a href="update-server-pda.html">Обновить PDA</a> <a href="update-server-pda.html">Обновить PDA</a>
<a href="read-pda.html">Просмотреть PDA</a>
</div> </div>
<h1>Регистрация серверного аккаунта</h1> <h1>Регистрация серверного аккаунта</h1>
@ -56,12 +57,12 @@
<h2>Данные сервера</h2> <h2>Данные сервера</h2>
<div class="field"> <div class="field">
<label>Логин сервера</label> <label>Логин сервера</label>
<input type="text" id="login" placeholder="shineupme" maxlength="20" /> <input type="text" id="login" placeholder="Логин сервера" maxlength="20" />
<div class="hint">Только a-z, 0-9, _ · без точки · макс. 20 символов</div> <div class="hint">Только a-z, 0-9, _ · без точки · макс. 20 символов</div>
</div> </div>
<div class="field"> <div class="field">
<label>Адрес сервера (URL)</label> <label>Адрес сервера (URL)</label>
<input type="text" id="serverAddress" placeholder="https://shineup.me/ws" /> <input type="text" id="serverAddress" placeholder="Адрес сервера" />
</div> </div>
<div class="field"> <div class="field">
<label>Серверы синхронизации (sync_servers)</label> <label>Серверы синхронизации (sync_servers)</label>
@ -97,6 +98,11 @@
<div class="sec-lbl">Ключевые пары (base58)</div> <div class="sec-lbl">Ключевые пары (base58)</div>
<div class="kp-block">
<div class="kp-title">Recovery Key — восстановление аккаунта</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="recoveryPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="recoveryPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block"> <div class="kp-block">
<div class="kp-title">Root Key — подпись PDA-записи</div> <div class="kp-title">Root Key — подпись PDA-записи</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="rootPub" placeholder="base58, ~44 символа" /></div> <div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="rootPub" placeholder="base58, ~44 символа" /></div>

View File

@ -16,9 +16,12 @@ import {
validateLoginOrThrow, validateLoginOrThrow,
wireDeviceAddressPreview, wireDeviceAddressPreview,
} from './server-ui-shared.js'; } from './server-ui-shared.js';
import { defaultServerAddress, defaultServerLogin } from '../../js/deploy-config.js';
const fieldMap = { const fieldMap = {
masterSecret: 'masterSecret', masterSecret: 'masterSecret',
recoveryPub: 'recoveryPub',
recoveryPriv: 'recoveryPriv',
rootPub: 'rootPub', rootPub: 'rootPub',
rootPriv: 'rootPriv', rootPriv: 'rootPriv',
bchPub: 'bchPub', bchPub: 'bchPub',
@ -32,6 +35,8 @@ const fieldMap = {
setupPasswordEye($('btnEye'), $('password')); setupPasswordEye($('btnEye'), $('password'));
wireDeviceAddressPreview(fieldMap); wireDeviceAddressPreview(fieldMap);
$('password').value = ''; $('password').value = '';
$('login').placeholder = defaultServerLogin;
$('serverAddress').placeholder = defaultServerAddress;
$('btnTopupDevnet').addEventListener('click', () => { $('btnTopupDevnet').addEventListener('click', () => {
try { try {

View File

@ -0,0 +1,242 @@
import { readShineUserPdaByRef } from '../../js/services/shine-user-pda-service.js';
import { bytesToBase58 } from '../../js/services/crypto-utils.js';
import { $, clearStatus, formatBigInt, formatTimestamp, setStatus } from './server-ui-shared.js';
function hex(bytes) {
const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
return Array.from(data).map((x) => x.toString(16).padStart(2, '0')).join('');
}
function base58(bytes) {
const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
return bytesToBase58(data);
}
function renderRows(rows) {
const container = $('summaryRows');
container.innerHTML = '';
for (const row of rows) {
const el = document.createElement('div');
el.className = 'field-row';
const label = document.createElement('div');
label.className = 'field-label';
label.textContent = row.label;
const value = document.createElement('div');
value.className = `field-value${row.muted ? ' muted' : ''}`;
value.textContent = row.value;
el.append(label, value);
container.appendChild(el);
}
}
function renderMiniGrid(items) {
const wrap = document.createElement('div');
wrap.className = 'mini-grid';
for (const item of items) {
const node = document.createElement('div');
node.className = 'mini-item';
const label = document.createElement('div');
label.className = 'mini-label';
label.textContent = item.label;
const value = document.createElement('div');
value.className = 'mini-value';
value.textContent = item.value;
node.append(label, value);
wrap.appendChild(node);
}
return wrap;
}
function renderBlock(title, subtitle, items) {
const card = document.createElement('div');
card.className = 'block-card';
const ttl = document.createElement('div');
ttl.className = 'block-title';
ttl.textContent = title;
card.appendChild(ttl);
if (subtitle) {
const sub = document.createElement('div');
sub.className = 'block-subtitle';
sub.textContent = subtitle;
card.appendChild(sub);
}
card.appendChild(renderMiniGrid(items));
return card;
}
function renderArrayLine(values, emptyLabel = '—') {
const arr = Array.isArray(values) ? values : [];
return arr.length ? arr.join(', ') : emptyLabel;
}
function renderSessionList(sessions) {
const list = document.createElement('div');
list.className = 'details-wrap';
const details = document.createElement('details');
details.open = true;
const summary = document.createElement('summary');
summary.textContent = `Сессии (${Array.isArray(sessions) ? sessions.length : 0})`;
const body = document.createElement('div');
body.className = 'details-body';
if (!Array.isArray(sessions) || sessions.length === 0) {
body.innerHTML = '<div class="field-value muted">Сессий нет.</div>';
} else {
const grid = document.createElement('div');
grid.className = 'block-list';
sessions.forEach((session, idx) => {
grid.appendChild(renderBlock(
`Session #${idx + 1}`,
`type=${session.sessionType}, version=${session.sessionVersion}`,
[
{ label: 'Имя', value: session.sessionName || '—' },
{ label: 'PubKey', value: base58(session.sessionPubKey32) },
],
));
});
body.appendChild(grid);
}
details.append(summary, body);
list.appendChild(details);
return list;
}
function renderParsed(parsed) {
$('summaryCard').style.display = 'block';
$('blocksCard').style.display = 'block';
$('rawCard').style.display = 'block';
renderRows([
{ label: 'PDA адрес', value: parsed.pdaAddress },
{ label: 'Логин', value: parsed.login },
{ label: 'Статус', value: parsed.isServer ? 'server' : 'not server' },
{ label: 'recordNumber', value: String(parsed.recordNumber) },
{ label: 'createdAtMs', value: `${parsed.createdAtMs} · ${formatTimestamp(parsed.createdAtMs)}` },
{ label: 'updatedAtMs', value: `${parsed.updatedAtMs} · ${formatTimestamp(parsed.updatedAtMs)}` },
{ label: 'recordLen', value: String(parsed.recordLen) },
{ label: 'prevRecordHash32', value: hex(parsed.prevRecordHash) },
{ label: 'signature64', value: base58(parsed.signature) },
{ label: 'unsignedBytesLen', value: String(parsed.unsignedBytes?.length || 0) },
]);
const badgeLine = $('badgeLine');
badgeLine.innerHTML = '';
const badges = [
{ label: `server=${parsed.isServer ? '1' : '0'}`, kind: parsed.isServer ? 'ok' : 'warn' },
{ label: `trusted=${Number(parsed.trustedCount || 0)}`, kind: Number(parsed.trustedCount || 0) > 0 ? 'ok' : 'warn' },
{ label: `sessions=${Array.isArray(parsed.sessions) ? parsed.sessions.length : 0}`, kind: 'info' },
{ label: `access=${Array.isArray(parsed.accessServers) ? parsed.accessServers.length : 0}`, kind: 'info' },
{ label: `sync=${Array.isArray(parsed.syncServers) ? parsed.syncServers.length : 0}`, kind: 'info' },
];
badges.forEach((item) => {
const badge = document.createElement('span');
badge.className = `badge ${item.kind}`;
badge.textContent = item.label;
badgeLine.appendChild(badge);
});
const blocks = $('blocksList');
blocks.innerHTML = '';
blocks.appendChild(renderBlock('RecoveryKeyBlock', 'block_type=0', [
{ label: 'recoveryKey32', value: base58(parsed.recoveryKey) },
]));
blocks.appendChild(renderBlock('RootKeyBlock', 'block_type=1', [
{ label: 'rootKey32', value: base58(parsed.rootKey) },
]));
blocks.appendChild(renderBlock('ClientKeyBlock', 'block_type=2', [
{ label: 'clientKey32', value: base58(parsed.clientKey) },
]));
blocks.appendChild(renderBlock('BlockchainRegistryBlock', 'block_type=3', [
{ label: 'blockchainType', value: String(parsed.blockchain?.blockchainType ?? 0) },
{ label: 'blockchainName', value: parsed.blockchain?.blockchainName || '—' },
{ label: 'blockchainPublicKey32', value: base58(parsed.blockchain?.blockchainPublicKey) },
{ label: 'paidLimitBytes', value: formatBigInt(parsed.blockchain?.paidLimitBytes || 0n) },
{ label: 'usedBytes', value: formatBigInt(parsed.blockchain?.usedBytes || 0n) },
{ label: 'lastBlockNumber', value: String(parsed.blockchain?.lastBlockNumber ?? 0) },
{ label: 'lastBlockHash32', value: hex(parsed.blockchain?.lastBlockHash) },
{ label: 'lastBlockSignature64', value: base58(parsed.blockchain?.lastBlockSignature) },
{ label: 'arweaveTxId', value: parsed.blockchain?.arweaveTxId || '—' },
]));
blocks.appendChild(renderBlock('ServerProfileBlock', 'block_type=30', [
{ label: 'isServer', value: parsed.isServer ? '1' : '0' },
{ label: 'addressFormatType', value: String(parsed.addressFormatType ?? 0) },
{ label: 'addressFormatVersion', value: String(parsed.addressFormatVersion ?? 0) },
{ label: 'serverAddress', value: parsed.serverAddress || '—' },
{ label: 'syncServersCount', value: String(parsed.syncServers?.length || 0) },
{ label: 'syncServers', value: renderArrayLine(parsed.syncServers) },
]));
blocks.appendChild(renderBlock('AccessServersBlock', 'block_type=40', [
{ label: 'accessServersCount', value: String(parsed.accessServers?.length || 0) },
{ label: 'accessServers', value: renderArrayLine(parsed.accessServers) },
]));
blocks.appendChild(renderBlock('SessionsBlock', 'block_type=50', [
{ label: 'sessionsMode', value: String(parsed.sessionsMode ?? 0) },
{ label: 'sessionsCount', value: String(parsed.sessions?.length || 0) },
{ label: 'sessions', value: parsed.sessions?.length ? 'ниже' : '—' },
]));
blocks.appendChild(renderBlock('TrustedStateBlock', 'block_type=70', [
{ label: 'trustedCount', value: String(parsed.trustedCount ?? 0) },
]));
blocks.appendChild(renderSessionList(parsed.sessions));
$('rawJson').textContent = JSON.stringify({
pdaAddress: parsed.pdaAddress,
login: parsed.login,
isServer: parsed.isServer,
recordNumber: parsed.recordNumber,
createdAtMs: parsed.createdAtMs.toString(),
updatedAtMs: parsed.updatedAtMs.toString(),
recordLen: parsed.recordLen,
trustedCount: parsed.trustedCount,
addressFormatType: parsed.addressFormatType,
addressFormatVersion: parsed.addressFormatVersion,
serverAddress: parsed.serverAddress,
syncServers: parsed.syncServers,
accessServers: parsed.accessServers,
sessionsMode: parsed.sessionsMode,
sessions: parsed.sessions.map((s) => ({
sessionType: s.sessionType,
sessionVersion: s.sessionVersion,
sessionName: s.sessionName,
sessionPubKey32: base58(s.sessionPubKey32),
})),
recoveryKey: base58(parsed.recoveryKey),
rootKey: base58(parsed.rootKey),
clientKey: base58(parsed.clientKey),
blockchain: {
blockchainType: parsed.blockchain.blockchainType,
blockchainName: parsed.blockchain.blockchainName,
blockchainPublicKey: base58(parsed.blockchain.blockchainPublicKey),
paidLimitBytes: parsed.blockchain.paidLimitBytes.toString(),
usedBytes: parsed.blockchain.usedBytes.toString(),
lastBlockNumber: parsed.blockchain.lastBlockNumber,
lastBlockHash: hex(parsed.blockchain.lastBlockHash),
lastBlockSignature: base58(parsed.blockchain.lastBlockSignature),
arweaveTxId: parsed.blockchain.arweaveTxId || '',
},
}, null, 2);
}
$('btnLoad').addEventListener('click', async () => {
clearStatus($('status'));
$('btnLoad').disabled = true;
$('summaryCard').style.display = 'none';
$('blocksCard').style.display = 'none';
$('rawCard').style.display = 'none';
try {
const endpoint = String($('endpoint').value || '').trim();
const ref = String($('ref').value || '').trim();
if (!endpoint) throw new Error('Укажите Solana endpoint');
if (!ref) throw new Error('Укажите логин или адрес PDA');
setStatus($('status'), 'Чтение PDA...', 'info');
const parsed = await readShineUserPdaByRef({ value: ref, solanaEndpoint: endpoint });
renderParsed(parsed);
setStatus($('status'), 'PDA загружена.', 'success');
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
} finally {
$('btnLoad').disabled = false;
}
});
document.body.dataset.ready = '1';

View File

@ -22,9 +22,12 @@ import {
validateLoginOrThrow, validateLoginOrThrow,
wireDeviceAddressPreview, wireDeviceAddressPreview,
} from './server-ui-shared.js'; } from './server-ui-shared.js';
import { defaultServerAddress, defaultServerLogin } from '../../js/deploy-config.js';
const fieldMap = { const fieldMap = {
masterSecret: 'masterSecret', masterSecret: 'masterSecret',
recoveryPub: 'recoveryPub',
recoveryPriv: 'recoveryPriv',
rootPub: 'rootPub', rootPub: 'rootPub',
rootPriv: 'rootPriv', rootPriv: 'rootPriv',
bchPub: 'bchPub', bchPub: 'bchPub',
@ -39,6 +42,7 @@ let currentPda = null;
function resetExpectedKeysUi() { function resetExpectedKeysUi() {
$('expectedKeysBox').style.display = 'none'; $('expectedKeysBox').style.display = 'none';
setText('expectedRecoveryPub', '');
setText('expectedRootPub', ''); setText('expectedRootPub', '');
setText('expectedBchPub', ''); setText('expectedBchPub', '');
setText('expectedDevPub', ''); setText('expectedDevPub', '');
@ -48,6 +52,7 @@ function resetExpectedKeysUi() {
function renderExpectedKeys(parsed) { function renderExpectedKeys(parsed) {
$('expectedKeysBox').style.display = 'block'; $('expectedKeysBox').style.display = 'block';
setText('expectedRecoveryPub', publicKeyBytesToBase58(parsed.recoveryKey));
setText('expectedRootPub', publicKeyBytesToBase58(parsed.rootKey)); setText('expectedRootPub', publicKeyBytesToBase58(parsed.rootKey));
setText('expectedBchPub', publicKeyBytesToBase58(parsed.blockchain.blockchainPublicKey)); setText('expectedBchPub', publicKeyBytesToBase58(parsed.blockchain.blockchainPublicKey));
setText('expectedDevPub', publicKeyBytesToBase58(parsed.clientKey)); setText('expectedDevPub', publicKeyBytesToBase58(parsed.clientKey));
@ -59,6 +64,7 @@ function compareCurrentFormKeysWithPda() {
const blockchainActual = String($('bchPub').value || '').trim(); const blockchainActual = String($('bchPub').value || '').trim();
return { return {
resultMap: { resultMap: {
recovery: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.recoveryKey), $('recoveryPub').value),
root: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.rootKey), $('rootPub').value), root: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.rootKey), $('rootPub').value),
blockchain: blockchainActual blockchain: blockchainActual
? compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.blockchain.blockchainPublicKey), blockchainActual) ? compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.blockchain.blockchainPublicKey), blockchainActual)
@ -92,6 +98,8 @@ function renderComparisonStatus() {
setupPasswordEye($('btnEye'), $('password')); setupPasswordEye($('btnEye'), $('password'));
wireDeviceAddressPreview(fieldMap); wireDeviceAddressPreview(fieldMap);
$('password').value = ''; $('password').value = '';
$('login').placeholder = defaultServerLogin;
$('serverAddress').placeholder = defaultServerAddress;
resetExpectedKeysUi(); resetExpectedKeysUi();
$('btnTopupDevnet').addEventListener('click', () => { $('btnTopupDevnet').addEventListener('click', () => {

View File

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Просмотр PDA — SHiNE Server Admin</title>
<link rel="stylesheet" href="styles.css" />
<style>
.field-row { display: grid; grid-template-columns: 180px 1fr; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); }
.field-row:last-child { border-bottom: 0; }
.field-label { color: var(--text-muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.field-value { font-family: monospace; font-size: 12px; word-break: break-all; white-space: pre-wrap; }
.field-value.muted { color: var(--text-muted); }
.block-list { display: grid; gap: 12px; }
.block-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: #121212; }
.block-title { font-size: 13px; font-weight: 700; color: var(--accent); margin-bottom: 10px; }
.block-subtitle { font-size: 11px; color: var(--text-muted); margin-bottom: 10px; }
.mini-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; }
.mini-item { min-width: 0; }
.mini-label { font-size: 11px; color: var(--text-muted); margin-bottom: 2px; text-transform: uppercase; letter-spacing: .04em; }
.mini-value { font-family: monospace; font-size: 12px; word-break: break-all; white-space: pre-wrap; }
.mono-box { font-family: monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; background: #0d0d0d; border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; }
.summary-grid { display: grid; gap: 0; }
.badge-line { display: flex; gap: 8px; flex-wrap: wrap; }
.badge { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px; font-size: 11px; color: var(--text-muted); background: #0d0d0d; }
.badge.ok { color: #7dcc7d; border-color: #2a4a2a; background: #1a2e1a; }
.badge.warn { color: #ffd37a; border-color: #5f4b22; background: #2f2614; }
.badge.err { color: #f08080; border-color: #5a2a2a; background: #2e1a1a; }
.details-wrap { margin-top: 12px; }
.details-wrap details { border: 1px solid var(--border); border-radius: var(--radius); background: #121212; }
.details-wrap summary { cursor: pointer; padding: 12px 14px; color: var(--accent); font-weight: 600; }
.details-body { padding: 0 14px 14px; }
.tight { margin-bottom: 8px; }
@media (max-width: 720px) {
.field-row { grid-template-columns: 1fr; gap: 4px; }
.mini-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<div class="nav-links">
<a href="../server-ui.html">← Назад</a>
<a href="create-server-pda.html">Создать PDA</a>
<a href="update-server-pda.html">Обновить PDA</a>
</div>
<h1>Просмотр PDA</h1>
<p class="subtitle">Читает user_pda или server PDA по логину либо по адресу и показывает все поля</p>
<div class="card">
<h2>Параметры Solana</h2>
<div class="field">
<label>Solana Endpoint</label>
<input type="text" id="endpoint" value="https://api.devnet.solana.com" />
</div>
</div>
<div class="card">
<h2>Источник PDA</h2>
<div class="field">
<label>Логин или адрес PDA</label>
<input type="text" id="ref" placeholder="Логин сервера / пользователя или base58-адрес PDA" />
<div class="hint">Если введён base58-адрес длиной 32 байта, он читается напрямую. Иначе значение трактуется как логин.</div>
</div>
<div class="btn-row">
<button class="btn-primary" id="btnLoad">Загрузить PDA</button>
</div>
<div class="status" id="status"></div>
</div>
<div class="card" id="summaryCard" style="display:none">
<h2>Сводка</h2>
<div class="summary-grid" id="summaryRows"></div>
<div class="badge-line" style="margin-top:12px" id="badgeLine"></div>
</div>
<div class="card" id="blocksCard" style="display:none">
<h2>Блоки и поля</h2>
<div class="block-list" id="blocksList"></div>
</div>
<div class="card" id="rawCard" style="display:none">
<h2>Raw JSON</h2>
<pre class="mono-box" id="rawJson"></pre>
</div>
</div>
<script type="module" src="./js/read-pda-page.js"></script>
</body>
</html>

View File

@ -46,6 +46,7 @@
<div class="nav-links"> <div class="nav-links">
<a href="../server-ui.html">← Назад</a> <a href="../server-ui.html">← Назад</a>
<a href="create-server-pda.html">Создать PDA</a> <a href="create-server-pda.html">Создать PDA</a>
<a href="read-pda.html">Просмотреть PDA</a>
</div> </div>
<h1>Обновление PDA сервера</h1> <h1>Обновление PDA сервера</h1>
@ -63,7 +64,7 @@
<h2>Загрузить существующую PDA</h2> <h2>Загрузить существующую PDA</h2>
<div class="field"> <div class="field">
<label>Логин сервера</label> <label>Логин сервера</label>
<input type="text" id="login" placeholder="shineupme" maxlength="20" /> <input type="text" id="login" placeholder="Логин сервера" maxlength="20" />
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button class="btn-secondary" id="btnLoad">Загрузить PDA</button> <button class="btn-secondary" id="btnLoad">Загрузить PDA</button>
@ -86,7 +87,7 @@
<h2>Новые параметры сервера</h2> <h2>Новые параметры сервера</h2>
<div class="field"> <div class="field">
<label>Новый адрес сервера (URL)</label> <label>Новый адрес сервера (URL)</label>
<input type="text" id="serverAddress" placeholder="https://shineup.me/ws" /> <input type="text" id="serverAddress" placeholder="Адрес сервера" />
</div> </div>
<div class="field"> <div class="field">
<label>Новые серверы синхронизации (sync_servers)</label> <label>Новые серверы синхронизации (sync_servers)</label>
@ -118,6 +119,11 @@
<div class="sec-lbl">Ключевые пары (base58)</div> <div class="sec-lbl">Ключевые пары (base58)</div>
<div class="kp-block">
<div class="kp-title">Recovery Key — восстановление аккаунта</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="recoveryPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="recoveryPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block"> <div class="kp-block">
<div class="kp-title">Root Key — подпись PDA-записи</div> <div class="kp-title">Root Key — подпись PDA-записи</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="rootPub" placeholder="base58, ~44 символа" /></div> <div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="rootPub" placeholder="base58, ~44 символа" /></div>
@ -143,7 +149,11 @@
</div> </div>
<div class="expected-card" id="expectedKeysBox" style="display:none"> <div class="expected-card" id="expectedKeysBox" style="display:none">
<div class="expected-ttl">Какие ключи ожидаются по уже загруженной PDA</div> <div class="expected-ttl">Какие ключи ожидаются по уже загруженной PDA</div>
<div class="expected-row">
<div class="expected-lbl">Ожидаемый recovery public key</div>
<div class="expected-val" id="expectedRecoveryPub"></div>
</div>
<div class="expected-row"> <div class="expected-row">
<div class="expected-lbl">Ожидаемый root public key</div> <div class="expected-lbl">Ожидаемый root public key</div>
<div class="expected-val" id="expectedRootPub"></div> <div class="expected-val" id="expectedRootPub"></div>