Новый протокол Solana PDA 1.2

This commit is contained in:
AidarKC
2026-09-26 11:01:14 +03:00
parent 6e5b57fd7c
commit c997057a37
47 changed files with 2392 additions and 4480 deletions
+3 -2
View File
@@ -43,8 +43,9 @@
- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
- `docs/Solana_Architecture/README.md`
- Документ формата пользовательской PDA-записи `shine_users` находится в:
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
- Документы формата пользовательской PDA-записи `shine_users`:
- текущий: `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.2.md`;
- legacy: `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`.
- Актуальная документация по серверному модулю синхронизации Solana users находится в:
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять:
@@ -52,113 +52,84 @@ function readStrU8(bytes, cursorRef) {
function parseServerFieldsFromUserPda(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
if (bytes.length < 5) throw new Error('Некорректный формат PDA');
if (bytes.length < 9) throw new Error('Некорректный формат PDA');
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const cursorRef = { value: 0 };
const readU16 = () => { if (cursorRef.value + 2 > bytes.length) throw new Error('Повреждённый формат PDA'); const v = view.getUint16(cursorRef.value, true); cursorRef.value += 2; return v; };
const readU32 = () => { if (cursorRef.value + 4 > bytes.length) throw new Error('Повреждённый формат PDA'); const v = view.getUint32(cursorRef.value, true); cursorRef.value += 4; return v; };
const readU64 = () => { if (cursorRef.value + 8 > bytes.length) throw new Error('Повреждённый формат PDA'); const v = view.getBigUint64(cursorRef.value, true); cursorRef.value += 8; return v; };
const magic = new TextDecoder().decode(readBytes(bytes, cursorRef, 5));
if (magic !== 'SHiNE') throw new Error('Некорректный формат PDA');
cursorRef.value += 1; // format_major
cursorRef.value += 1; // format_minor
cursorRef.value += 2; // record_len
const formatMajor = readU8(bytes, cursorRef);
const formatMinor = readU8(bytes, cursorRef);
if (formatMajor !== 1 || formatMinor !== 2) throw new Error(`Неподдерживаемый формат PDA ${formatMajor}.${formatMinor}`);
const recordLen = readU16();
if (recordLen < 73 || recordLen > bytes.length) throw new Error('Некорректный record_len PDA');
cursorRef.value += 8; // created_at_ms
cursorRef.value += 8; // updated_at_ms
cursorRef.value += 4; // record_number
cursorRef.value += 32; // prev_record_hash
readStrU8(bytes, cursorRef); // login
const login = readStrU8(bytes, cursorRef);
const blocksCount = readU8(bytes, cursorRef);
let isServer = false;
let serverAddress = '';
let accessServers = [];
let recoveryKey32 = null;
let rootKey32 = null;
let clientKey32 = null;
let blockchainKey32 = null;
let blockchainName = '';
let homeserverSessions = [];
let forkCount = 0;
for (let i = 0; i < blocksCount; i += 1) {
const blockType = readU8(bytes, cursorRef);
cursorRef.value += 1; // block_version
if (blockType === 0 || blockType === 1 || blockType === 2) {
const blockVersion = readU8(bytes, cursorRef);
if (blockVersion !== 0) throw new Error('Неподдерживаемая версия блока PDA');
if (blockType === 1 || blockType === 2) {
const key32 = readBytes(bytes, cursorRef, 32);
if (blockType === 0) recoveryKey32 = key32;
if (blockType === 1) rootKey32 = key32;
if (blockType === 2) clientKey32 = key32;
else clientKey32 = key32;
continue;
}
const payloadLen = readU16();
const payloadEnd = cursorRef.value + payloadLen;
if (payloadEnd > recordLen - 64) throw new Error('Повреждённый variable block PDA');
if (blockType === 3) {
const count = readU8(bytes, cursorRef);
for (let j = 0; j < count; j += 1) {
cursorRef.value += 1;
const currentBlockchainName = readStrU8(bytes, cursorRef);
const currentBlockchainKey32 = readBytes(bytes, cursorRef, 32);
if (!blockchainKey32) {
blockchainKey32 = currentBlockchainKey32;
blockchainName = currentBlockchainName;
}
cursorRef.value += 8 + 8 + 4 + 32 + 64;
const arPresent = readU8(bytes, cursorRef);
if (arPresent === 1) readStrU8(bytes, cursorRef);
forkCount = readU16();
if (forkCount < 1) throw new Error('BlockchainRegistry пуст');
for (let j = 0; j < forkCount; j += 1) {
blockchainKey32 = readBytes(bytes, cursorRef, 32);
readU64(); // created_at_ms fork
readU32(); // paid_limit_bytes
}
continue;
}
if (blockType === 30) {
isServer = readU8(bytes, cursorRef) === 1;
if (isServer) {
cursorRef.value += 1; // address_format_type
cursorRef.value += 1; // address_format_version
serverAddress = readStrU8(bytes, cursorRef);
const syncCount = readU8(bytes, cursorRef);
for (let j = 0; j < syncCount; j += 1) readStrU8(bytes, cursorRef);
}
continue;
}
if (blockType === 40) {
} else if (blockType === 30) {
const addressCount = readU8(bytes, cursorRef);
if (addressCount !== 1) throw new Error('PDA 1.2 допускает ровно один адрес сервера');
readU8(bytes, cursorRef); // address_format_type
readU8(bytes, cursorRef); // address_format_version
serverAddress = readStrU8(bytes, cursorRef);
} else if (blockType === 40) {
const accessCount = readU8(bytes, cursorRef);
if (accessCount > 1) throw new Error('PDA 1.2 допускает максимум один access server');
accessServers = [];
for (let j = 0; j < accessCount; j += 1) accessServers.push(readStrU8(bytes, cursorRef));
continue;
}
if (blockType === 50) {
cursorRef.value += 1;
const sessionsCount = readU8(bytes, cursorRef);
for (let j = 0; j < sessionsCount; j += 1) {
const sessionType = readU8(bytes, cursorRef);
const sessionVersion = readU8(bytes, cursorRef);
const sessionName = readStrU8(bytes, cursorRef);
const sessionPubKey32 = readBytes(bytes, cursorRef, 32);
if (sessionType === 100) {
homeserverSessions.push({
sessionType,
sessionVersion,
sessionName,
sessionPubKeyBase58: new PublicKey(sessionPubKey32).toBase58(),
sessionPubKeyB64: `ed25519/${btoa(String.fromCharCode(...sessionPubKey32))}`,
});
}
}
continue;
}
if (blockType === 70) {
cursorRef.value += 1;
continue;
}
throw new Error(`Неизвестный блок PDA: ${blockType}`);
cursorRef.value = payloadEnd;
}
if (!rootKey32 || !clientKey32 || !blockchainKey32) throw new Error('PDA 1.2 не содержит обязательные ключи');
return {
isServer,
isServer: Boolean(serverAddress),
serverAddress: normalizeHostLike(serverAddress),
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
publicKeys: {
recoveryKeyBase58: recoveryKey32 ? new PublicKey(recoveryKey32).toBase58() : '',
rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '',
clientKeyBase58: clientKey32 ? new PublicKey(clientKey32).toBase58() : '',
blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '',
blockchainName,
recoveryKeyBase58: '',
rootKeyBase58: new PublicKey(rootKey32).toBase58(),
clientKeyBase58: new PublicKey(clientKey32).toBase58(),
blockchainKeyBase58: new PublicKey(blockchainKey32).toBase58(),
blockchainName: `${normalizeServerLogin(login)}-${String(forkCount).padStart(3, '0')}`,
},
homeserverSessions,
homeserverSessions: [],
};
}
+7 -15
View File
@@ -18,15 +18,11 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
## Настройка сервера в Solana (Solana PDA)
Серверный аккаунт SHiNE регистрируется в Solana в виде `user_pda` с флагом `is_server=true`.
В PDA хранятся:
Серверный аккаунт SHiNE регистрируется как обычный `user_pda` формата 1.2 с присутствующим `ServerProfileBlock`. Отдельного `is_server` в PDA нет: наличие server profile означает, что аккаунт объявляет серверный endpoint.
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
с которыми синхронизируются пользовательские блокчейны;
- **корневой ключ** сервера (`root_key`).
PDA 1.2 сейчас допускает **один** адрес сервера (`address_format_type + address_format_version + address`). Бинарный блок использует count/array, чтобы будущая версия протокола могла увеличить лимит без смены структуры блока. Прямой список `sync_servers` удалён: пользовательские blockchain синхронизируются через Arweave.
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
Клиенты читают server PDA из Solana, получают опубликованный endpoint и подключаются к нему. Межсерверная доставка DM через access-server routing остаётся отдельным механизмом.
**Управление серверной PDA выполняется через Web-панель администратора:**
@@ -36,10 +32,9 @@ shine-UI/server-ui.html
Страницы:
- `shine-UI/server-ui/create-server-pda.html` — первичная регистрация серверного аккаунта;
- `shine-UI/server-ui/update-server-pda.html` — обновление адреса или списка sync_servers.
- `shine-UI/server-ui/update-server-pda.html` — обновление адреса сервера.
Для регистрации нужен полный keyBundle (root + device + blockchain).
Для обновления — только root + device (blockchain-ключ не нужен).
PDA 1.2 использует три постоянные роли ключей: `root` (cold recovery), `blockchain` (обычный authority и подпись блоков) и `client` (клиент/кошелёк). Обычный update авторизуется активным последним blockchain key; root используется для recovery и может менять root.
Актуальные адреса программ Solana (devnet):
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
@@ -47,12 +42,9 @@ shine-UI/server-ui.html
Подробнее: `docs/Инициализация_Solana_регистрации/README.md`
## Синхронизация с партнёрскими серверами
## Синхронизация пользовательских blockchain
Сервер должен синхронизировать блоки пользовательских блокчейнов с
серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются:
они доставляются только на первый access-сервер получателя.
Детали: `docs/Blockchain/sync-between-servers.md`
Прямой peer-to-peer blockchain sync через `sync_servers` отключён. Сервер восстанавливает и синхронизирует пользовательские записи через Arweave. Код старого peer-sync пока может оставаться как inert legacy, но не должен включаться в runtime.
## Деплой
@@ -23,6 +23,46 @@ public final class KeyEncodingUtil {
private KeyEncodingUtil() {}
public static String encodeBase58(byte[] input) {
if (input == null || input.length == 0) {
return "";
}
byte[] copy = java.util.Arrays.copyOf(input, input.length);
int zeros = 0;
while (zeros < copy.length && copy[zeros] == 0) {
zeros++;
}
byte[] encoded = new byte[copy.length * 2];
int outputStart = encoded.length;
int inputStart = zeros;
while (inputStart < copy.length) {
int remainder = divmod58(copy, inputStart);
if (copy[inputStart] == 0) {
inputStart++;
}
encoded[--outputStart] = (byte) BASE58_ALPHABET.charAt(remainder);
}
while (outputStart < encoded.length && encoded[outputStart] == (byte) BASE58_ALPHABET.charAt(0)) {
outputStart++;
}
while (--zeros >= 0) {
encoded[--outputStart] = (byte) BASE58_ALPHABET.charAt(0);
}
return new String(encoded, outputStart, encoded.length - outputStart, java.nio.charset.StandardCharsets.US_ASCII);
}
public static String base58KeyToBase64_32(String rawKey) {
if (rawKey == null) {
return null;
}
String value = rawKey.trim();
if (value.isEmpty()) {
return value;
}
byte[] decoded = tryDecodeBase58_32(value);
return decoded == null ? value : Base64.getEncoder().encodeToString(decoded);
}
public static String normalizeKeyToBase64_32(String rawKey) {
if (rawKey == null) {
return null;
@@ -102,6 +142,17 @@ public final class KeyEncodingUtil {
return result;
}
private static int divmod58(byte[] number, int startAt) {
int remainder = 0;
for (int i = startAt; i < number.length; i++) {
int digit256 = number[i] & 0xFF;
int temp = remainder * 256 + digit256;
number[i] = (byte) (temp / 58);
remainder = temp % 58;
}
return remainder;
}
private static int divmod256(byte[] number58, int startAt) {
int remainder = 0;
for (int i = startAt; i < number58.length; i++) {
@@ -8,7 +8,6 @@ import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Base64;
/**
* Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем.
@@ -61,28 +60,35 @@ public final class SolanaUserPdaCurrentDAO {
}
}
/** Resolve a current user by the 32-byte Ed25519 blockchain key used as ANS-104 owner. */
/** Resolve a user/fork by the 32-byte Ed25519 blockchain key used as ANS-104 owner. */
public SolanaUserPdaCurrentEntry getByBlockchainKey(byte[] owner32) throws SQLException {
if (owner32 == null || owner32.length != 32) return null;
String wanted = Base64.getEncoder().encodeToString(owner32);
String wanted = KeyEncodingUtil.encodeBase58(owner32);
String sql = """
SELECT login, blockchain_name, blockchain_key, paid_limit_bytes
FROM solana_user_pda_current
SELECT
u.login,
u.login || '-' || LPAD(((fork_item.value ->> 'forkIndex')::integer + 1)::text, 3, '0') AS blockchain_name,
fork_item.value ->> 'blockchainKey' AS blockchain_key,
(fork_item.value ->> 'paidLimitBytes')::bigint AS paid_limit_bytes
FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(NULLIF(u.blockchain_forks_json, ''), '[]')::jsonb) AS fork_item(value)
WHERE fork_item.value ->> 'blockchainKey' = ?
LIMIT 1
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql); ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
SolanaUserPdaCurrentEntry e = mapRow(rs);
if (wanted.equals(e.getBlockchainKey())) return e;
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, wanted);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
return null;
}
private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException {
SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry();
entry.setLogin(rs.getString("login"));
entry.setBlockchainName(rs.getString("blockchain_name"));
entry.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key")));
entry.setBlockchainKey(KeyEncodingUtil.base58KeyToBase64_32(rs.getString("blockchain_key")));
entry.setPaidLimitBytes(rs.getLong("paid_limit_bytes"));
return entry;
}
@@ -1,11 +1,8 @@
-- Turbo/direct-Arweave transport no longer stores root transaction IDs for user DataItems.
ALTER TABLE blocks
DROP COLUMN IF EXISTS arweave_root_tx_id;
ALTER TABLE arweave_block_import_queue
DROP COLUMN IF EXISTS root_tx_id;
-- PDA 1.2: keep the complete append-only blockchain-key/fork history alongside the active compatibility columns.
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS blockchain_forks_json TEXT NOT NULL DEFAULT '[]';
UPDATE db_schema_version
SET schema_version=25,
updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)
WHERE id=1;
SET schema_version = 25,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE id = 1;
@@ -84,6 +84,7 @@ CREATE TABLE IF NOT EXISTS solana_user_pda_current (
blockchain_name TEXT NOT NULL,
blockchain_key TEXT NOT NULL,
paid_limit_bytes BIGINT NOT NULL,
blockchain_forks_json TEXT NOT NULL DEFAULT '[]',
used_bytes BIGINT NOT NULL,
last_block_number INTEGER NOT NULL,
last_block_hash TEXT NOT NULL,
@@ -500,6 +501,7 @@ CREATE TABLE IF NOT EXISTS blocks (
data_item_id BYTEA NOT NULL,
arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE,
arweave_published_at_ms BIGINT,
arweave_root_tx_id TEXT,
edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0),
line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0),
prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0),
@@ -534,6 +536,7 @@ VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
data_item_id BYTEA PRIMARY KEY,
root_tx_id TEXT NOT NULL,
block_height BIGINT NOT NULL,
raw_data_item BYTEA NOT NULL,
status TEXT NOT NULL,
@@ -2079,7 +2082,7 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
WHERE archive_head_tx_id <> '';
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
VALUES(1,25,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
COMMIT;
@@ -28,7 +28,6 @@ public final class SolanaUserPdaImportService {
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final String MAGIC = "SHiNE";
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1;
private static final int ARCHIVE_HEAD_PAYLOAD_BYTES = 64;
private SolanaUserPdaImportService() {}
@@ -55,8 +54,7 @@ public final class SolanaUserPdaImportService {
}
/**
* Чтение server PDA по логину сервера. Используется сервером при старте,
* чтобы получить актуальный server_address и список sync_servers.
* Чтение server PDA 1.2 по логину сервера. В 1.2 разрешён ровно один endpoint.
*/
public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception {
String login = normalizeLogin(loginRaw);
@@ -156,9 +154,16 @@ public final class SolanaUserPdaImportService {
}
private static ParsedSolanaUser parseUserPda(byte[] raw) {
if (raw == null || raw.length < 128) return null;
if (raw == null || raw.length < 9) return null;
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
int major = u8(raw, 5);
int minor = u8(raw, 6);
if (major != 1 || minor != 2) return null;
return parseUserPdaV12(raw);
}
private static ParsedSolanaUser parseUserPdaV12(byte[] raw) {
if (raw == null || raw.length < 128) return null;
int recordLen = u16le(raw, 7);
if (recordLen < 73 || recordLen > raw.length) return null;
@@ -167,228 +172,166 @@ public final class SolanaUserPdaImportService {
c += 8; // updated_at_ms
c += 4; // record_number
c += 32; // prev_record_hash
if (c >= recordLen) return null;
int loginLen = u8(raw, c++);
if (loginLen <= 0 || c + loginLen > recordLen) return null;
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
c += loginLen;
if (c >= recordLen) return null;
int blocksCount = u8(raw, c++);
String blockchainName = null;
byte[] blockchainKey32 = null;
byte[] clientKey32 = null;
long paidLimitBytes = 0L;
List<ParsedSessionRecord> sessions = new ArrayList<>();
int forkCount = 0;
List<String> accessServers = new ArrayList<>();
for (int i = 0; i < blocksCount; i++) {
if (c + 2 > recordLen) return null;
int blockType = u8(raw, c++);
int blockVer = u8(raw, c++);
if (blockVer != 0) return null;
if (blockType == 0) {
c += 32; // recovery_key
} else if (blockType == 1) {
c += 32;
} else if (blockType == 2) {
if (blockType == 1) {
if (c + 32 > recordLen) return null;
c += 32; // root_key
continue;
}
if (blockType == 2) {
if (c + 32 > recordLen) return null;
clientKey32 = slice(raw, c, 32);
c += 32;
} else if (blockType == 3) {
int count = u8(raw, c++);
for (int j = 0; j < count; j++) {
c += 1; // blockchain_type
int bchLen = u8(raw, c++);
blockchainName = new String(raw, c, bchLen, StandardCharsets.UTF_8);
c += bchLen;
blockchainKey32 = slice(raw, c, 32);
c += 32;
paidLimitBytes = u64le(raw, c);
c += 8;
c += 8; // used_bytes
c += 4; // last_block_number
c += 32; // last_block_hash
c += 64; // last_block_signature
int arweavePresent = u8(raw, c++);
if (arweavePresent == 1) {
int arLen = u8(raw, c++);
c += arLen;
} else if (arweavePresent != 0) {
return null;
}
}
} else if (blockType == 30) {
int isServer = u8(raw, c++);
if (isServer == 1) {
c += 1; // address_format_type
c += 1; // address_format_version
int addrLen = u8(raw, c++);
c += addrLen;
int syncCount = u8(raw, c++);
for (int j = 0; j < syncCount; j++) {
int n = u8(raw, c++);
c += n;
}
} else if (isServer != 0) {
return null;
}
} else if (blockType == 40) {
int accessCount = u8(raw, c++);
for (int j = 0; j < accessCount; j++) {
int n = u8(raw, c++);
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
String normalizedAccessServerLogin = normalizeLogin(accessServerLogin);
if (normalizedAccessServerLogin == null || accessServers.contains(normalizedAccessServerLogin)) {
continue;
}
if (accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
accessServers.add(normalizedAccessServerLogin);
}
}
} else if (blockType == 50) {
int sessionsMode = u8(raw, c++);
if (sessionsMode != 1 && sessionsMode != 10) return null;
int sessionsCount = u8(raw, c++);
if (sessionsCount > 64) return null;
for (int j = 0; j < sessionsCount; j++) {
int sessionType = u8(raw, c++);
int sessionVersion = u8(raw, c++);
int n = u8(raw, c++);
String sessionName = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
byte[] sessionPubKey32 = slice(raw, c, 32);
c += 32;
sessions.add(new ParsedSessionRecord(
sessionType,
sessionVersion,
sessionName,
sessionPubKey32
));
}
} else if (blockType == 70) {
c += 1;
} else if (blockType == 100) {
c += ARCHIVE_HEAD_PAYLOAD_BYTES;
} else {
return null;
continue;
}
if (c > recordLen) return null;
if (c + 2 > recordLen) return null;
int payloadLen = u16le(raw, c);
c += 2;
int payloadEnd = c + payloadLen;
if (payloadEnd < c || payloadEnd > recordLen) return null;
if (blockType == 3) {
if (c + 2 > payloadEnd) return null;
forkCount = u16le(raw, c);
c += 2;
if (forkCount <= 0) return null;
for (int j = 0; j < forkCount; j++) {
if (c + 44 > payloadEnd) return null;
blockchainKey32 = slice(raw, c, 32);
c += 32;
c += 8; // fork created_at_ms
paidLimitBytes = u32le(raw, c);
c += 4;
}
if (c != payloadEnd) return null;
} else if (blockType == 30) {
// Server profile is not needed for auth parsing; validate/skip its payload.
c = payloadEnd;
} else if (blockType == 40) {
if (c >= payloadEnd) return null;
int accessCount = u8(raw, c++);
if (accessCount > 1) return null;
for (int j = 0; j < accessCount; j++) {
if (c >= payloadEnd) return null;
int n = u8(raw, c++);
if (c + n > payloadEnd) return null;
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
String normalized = normalizeLogin(accessServerLogin);
if (normalized != null && !accessServers.contains(normalized)
&& accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
accessServers.add(normalized);
}
}
if (c != payloadEnd) return null;
} else {
// Unknown variable PDA 1.2 block: forward-compatible skip by payload_len.
c = payloadEnd;
}
c = payloadEnd;
}
if (blockchainName == null || blockchainKey32 == null || clientKey32 == null) return null;
if (blockchainKey32 == null || clientKey32 == null || forkCount <= 0) return null;
String blockchainName = login + "-" + String.format(Locale.ROOT, "%03d", forkCount);
return new ParsedSolanaUser(
login,
blockchainName,
Base64.getEncoder().encodeToString(blockchainKey32),
Base64.getEncoder().encodeToString(clientKey32),
paidLimitBytes,
sessions,
List.of(),
accessServers
);
}
private static ParsedServerProfile parseServerProfile(byte[] raw) {
if (raw == null || raw.length < 128) return null;
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
private static ParsedServerProfile parseServerProfile(byte[] raw) {
if (raw == null || raw.length < 9) return null;
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
int major = u8(raw, 5);
int minor = u8(raw, 6);
if (major != 1 || minor != 2) return null;
return parseServerProfileV12(raw);
}
private static ParsedServerProfile parseServerProfileV12(byte[] raw) {
if (raw == null || raw.length < 128) return null;
int recordLen = u16le(raw, 7);
if (recordLen < 73 || recordLen > raw.length) return null;
int c = 9;
c += 8; // created_at_ms
c += 8; // updated_at_ms
c += 4; // record_number
c += 32; // prev_record_hash
int c = 9 + 8 + 8 + 4 + 32;
if (c >= recordLen) return null;
int loginLen = u8(raw, c++);
if (loginLen <= 0 || c + loginLen > recordLen) return null;
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
c += loginLen;
if (c >= recordLen) return null;
int blocksCount = u8(raw, c++);
boolean isServer = false;
String serverAddress = "";
List<String> syncServers = new ArrayList<>();
for (int i = 0; i < blocksCount; i++) {
if (c + 2 > recordLen) return null;
int blockType = u8(raw, c++);
int blockVer = u8(raw, c++);
if (blockVer != 0) return null;
if (blockType == 0 || blockType == 1 || blockType == 2) {
if (blockType == 1 || blockType == 2) {
if (c + 32 > recordLen) return null;
c += 32;
} else if (blockType == 3) {
int count = u8(raw, c++);
for (int j = 0; j < count; j++) {
c += 1; // blockchain_type
int bchLen = u8(raw, c++);
c += bchLen;
c += 32; // blockchain pubkey
c += 8; // paid_limit_bytes
c += 8; // used_bytes
c += 4; // last_block_number
c += 32; // last_block_hash
c += 64; // last_block_signature
int arweavePresent = u8(raw, c++);
if (arweavePresent == 1) {
int arLen = u8(raw, c++);
c += arLen;
} else if (arweavePresent != 0) {
return null;
}
}
} else if (blockType == 30) {
int isServerValue = u8(raw, c++);
if (isServerValue == 1) {
isServer = true;
continue;
}
if (c + 2 > recordLen) return null;
int payloadLen = u16le(raw, c);
c += 2;
int payloadEnd = c + payloadLen;
if (payloadEnd < c || payloadEnd > recordLen) return null;
if (blockType == 30) {
if (c >= payloadEnd) return null;
int addressCount = u8(raw, c++);
if (addressCount != 1) return null;
for (int j = 0; j < addressCount; j++) {
if (c + 3 > payloadEnd) return null;
c += 1; // address_format_type
c += 1; // address_format_version
int addrLen = u8(raw, c++);
serverAddress = new String(raw, c, addrLen, StandardCharsets.UTF_8);
c += addrLen;
int syncCount = u8(raw, c++);
for (int j = 0; j < syncCount; j++) {
int n = u8(raw, c++);
String syncLogin = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
syncServers.add(normalizeLogin(syncLogin));
int n = u8(raw, c++);
if (c + n > payloadEnd) return null;
String address = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
if (!isServer) {
isServer = true;
serverAddress = address;
}
} else if (isServerValue != 0) {
return null;
}
} else if (blockType == 40) {
int accessCount = u8(raw, c++);
for (int j = 0; j < accessCount; j++) {
int n = u8(raw, c++);
c += n;
}
} else if (blockType == 50) {
int sessionsMode = u8(raw, c++);
if (sessionsMode != 1 && sessionsMode != 10) return null;
int sessionsCount = u8(raw, c++);
if (sessionsCount > 64) return null;
for (int j = 0; j < sessionsCount; j++) {
c += 1; // session_type
c += 1; // session_version
int n = u8(raw, c++);
c += n;
c += 32;
}
} else if (blockType == 70) {
c += 1;
} else if (blockType == 100) {
c += ARCHIVE_HEAD_PAYLOAD_BYTES;
} else {
return null;
if (c != payloadEnd) return null;
}
if (c > recordLen) return null;
c = payloadEnd;
}
return new ParsedServerProfile(login, isServer, serverAddress, syncServers);
return new ParsedServerProfile(login, isServer, serverAddress, List.of());
}
private static String normalizeLogin(String login) {
if (login == null) return null;
String s = login.trim();
@@ -400,6 +343,12 @@ public final class SolanaUserPdaImportService {
private static int u16le(byte[] b, int o) {
return (b[o] & 0xFF) | ((b[o + 1] & 0xFF) << 8);
}
private static long u32le(byte[] b, int o) {
return ((long) b[o] & 0xFFL)
| (((long) b[o + 1] & 0xFFL) << 8)
| (((long) b[o + 2] & 0xFFL) << 16)
| (((long) b[o + 3] & 0xFFL) << 24);
}
private static long u64le(byte[] b, int o) {
long out = 0L;
for (int i = 0; i < 8; i++) out |= ((long) (b[o + i] & 0xFF)) << (8 * i);
@@ -63,7 +63,8 @@ public final class AddBlockSyncService {
}
private static boolean isEnabled() {
return AppConfig.getInstance().getBoolean("blockchain.sync.enabled", true);
// Legacy peer replication is disabled in PDA 1.2; Arweave is the synchronization source.
return false;
}
private void replicate(String blockchainName, int blockNumber) throws Exception {
@@ -415,9 +415,12 @@ public final class SolanaUsersSyncService
List<ParsedTxEnvelope> envelopes =
new ArrayList<>();
Set<String> updatePdaAddresses =
Set<String> exactFetchPdaAddresses =
new LinkedHashSet<>();
Map<String, String> latestRelevantSignatureByPda =
new LinkedHashMap<>();
for (SolanaRpcClient.SignatureRecord signatureRecord : chronologicalSignatures) {
JsonNode transaction =
@@ -436,20 +439,14 @@ public final class SolanaUsersSyncService
);
if (envelope.parsedInstruction() != null
&& envelope.parsedInstruction().kind() == ShineUsersCodec.TxKind.UPDATE_USER_PDA
&& envelope.parsedInstruction().affectedPdaAddress() != null) {
updatePdaAddresses.add(
envelope.parsedInstruction()
.affectedPdaAddress()
);
&& envelope.parsedInstruction().affectedPdaAddress() != null
&& envelope.parsedInstruction().relevant()) {
String changedPda = envelope.parsedInstruction().affectedPdaAddress();
latestRelevantSignatureByPda.put(changedPda, envelope.signatureRecord().signature());
exactFetchPdaAddresses.add(changedPda);
}
}
Map<String, ShineUsersCodec.UserPdaSnapshot> currentSnapshots =
storage.getCurrentSnapshots(
updatePdaAddresses
);
List<PostgresStorageRepository.TxHistoryEntry> txEntries =
new ArrayList<>();
@@ -492,71 +489,16 @@ public final class SolanaUsersSyncService
parsedInstruction.economyConfigState();
}
if (parsedInstruction.relevant()
&& parsedInstruction.userPdaMutation() != null) {
if (parsedInstruction.relevant()) {
relevant = true;
affectedPdaAddress = parsedInstruction.affectedPdaAddress();
affectedLogin = parsedInstruction.affectedLogin();
ShineUsersCodec.UserPdaSnapshot snapshot;
lastRelevantSignature = envelope.signatureRecord().signature();
lastRelevantSlot = envelope.signatureRecord().slot();
if (parsedInstruction.kind() == ShineUsersCodec.TxKind.CREATE_USER_PDA) {
if (economyState == null) {
economyState =
ShineUsersCodec.EconomyConfigState.initial();
log.warn(
"Economy config state was absent while processing create tx {}. Falling back to initial constants.",
envelope.signatureRecord().signature()
);
}
snapshot =
ShineUsersCodec.buildCreateSnapshot(
parsedInstruction.userPdaMutation(),
economyState,
envelope.signatureRecord().signature(),
envelope.signatureRecord().slot()
);
} else {
ShineUsersCodec.UserPdaSnapshot previous =
currentSnapshots.get(
affectedPdaAddress
);
if (previous == null) {
throw new IllegalStateException(
"Missing previous snapshot for update PDA " +
affectedPdaAddress
);
}
snapshot =
ShineUsersCodec.buildUpdateSnapshot(
parsedInstruction.userPdaMutation(),
previous,
envelope.signatureRecord().signature(),
envelope.signatureRecord().slot()
);
}
currentSnapshots.put(
snapshot.pdaAddress(),
snapshot
);
snapshotsToPersist.add(
snapshot
);
lastRelevantSignature =
envelope.signatureRecord().signature();
lastRelevantSlot =
envelope.signatureRecord().slot();
}
}
@@ -579,6 +521,25 @@ public final class SolanaUsersSyncService
fetchResult.signatures()
.get(0);
// PDA 1.2 содержит поля, вычисляемые программой и подписанные как единый документ.
// Поэтому для нового формата не реконструируем байты из instruction args, а читаем
// фактическое текущее состояние затронутых PDA из Solana.
if (!exactFetchPdaAddresses.isEmpty()) {
SolanaRpcClient.AccountBatchResult exactAccounts =
rpcClient.getCurrentAccounts(exactFetchPdaAddresses, newestSeen.slot());
for (ProgramAccountUpdate account : exactAccounts.updates()) {
ShineUsersCodec.UserPdaSnapshot snapshot =
ShineUsersCodec.parseUserPdaAccount(
account.address(),
account.slot(),
account.dataBase64(),
latestRelevantSignatureByPda.getOrDefault(account.address(), "")
);
snapshotsToPersist.removeIf(existing -> existing.pdaAddress().equals(snapshot.pdaAddress()));
snapshotsToPersist.add(snapshot);
}
}
PostgresStorageRepository.SyncStateSnapshot newState =
new PostgresStorageRepository.SyncStateSnapshot(
"READY",
@@ -462,7 +462,7 @@ public final class PostgresStorageRepository
"INSERT INTO solana_user_pda_current (" +
"pda_address, login, normalized_login, record_number, slot, last_tx_signature, " +
"recovery_key, root_key, client_key, blockchain_name, " +
"blockchain_key, paid_limit_bytes, used_bytes, " +
"blockchain_key, paid_limit_bytes, blockchain_forks_json, used_bytes, " +
"last_block_number, last_block_hash, last_block_signature, " +
"arweave_tx_id, archive_head_tx_id, archive_head_hash, is_server, address_format_type, " +
"address_format_version, server_address, sync_servers_json, " +
@@ -470,7 +470,7 @@ public final class PostgresStorageRepository
"trusted_count, created_at_ms, updated_at_ms, " +
"prev_record_hash, record_signature, raw_data_base64, " +
"first_seen_at_ms, last_synced_at_ms" +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT (pda_address) DO UPDATE SET " +
"login = EXCLUDED.login, " +
"normalized_login = EXCLUDED.normalized_login, " +
@@ -483,6 +483,7 @@ public final class PostgresStorageRepository
"blockchain_name = EXCLUDED.blockchain_name, " +
"blockchain_key = EXCLUDED.blockchain_key, " +
"paid_limit_bytes = EXCLUDED.paid_limit_bytes, " +
"blockchain_forks_json = EXCLUDED.blockchain_forks_json, " +
"used_bytes = EXCLUDED.used_bytes, " +
"last_block_number = EXCLUDED.last_block_number, " +
"last_block_hash = EXCLUDED.last_block_hash, " +
@@ -595,6 +596,7 @@ public final class PostgresStorageRepository
statement.setString(i++, snapshot.blockchainName());
statement.setString(i++, snapshot.blockchainKey());
statement.setLong(i++, snapshot.paidLimitBytes());
statement.setString(i++, writeJson(snapshot.blockchainForks()));
statement.setLong(i++, snapshot.usedBytes());
statement.setInt(i++, snapshot.lastBlockNumber());
statement.setString(i++, snapshot.lastBlockHash());
@@ -652,6 +654,17 @@ public final class PostgresStorageRepository
)
);
String forksJson = resultSet.getString("blockchain_forks_json");
List<ShineUsersCodec.BlockchainForkSnapshot> blockchainForks =
forksJson == null || forksJson.isBlank()
? List.of()
: Arrays.asList(
mapper.readValue(
forksJson,
ShineUsersCodec.BlockchainForkSnapshot[].class
)
);
return new ShineUsersCodec.UserPdaSnapshot(
resultSet.getString("pda_address"),
resultSet.getString("login"),
@@ -684,7 +697,8 @@ public final class PostgresStorageRepository
resultSet.getLong("updated_at_ms"),
resultSet.getString("prev_record_hash"),
resultSet.getString("record_signature"),
resultSet.getString("raw_data_base64")
resultSet.getString("raw_data_base64"),
List.copyOf(blockchainForks)
);
}
@@ -836,6 +850,7 @@ public final class PostgresStorageRepository
"blockchain_name TEXT NOT NULL, " +
"blockchain_key TEXT NOT NULL, " +
"paid_limit_bytes BIGINT NOT NULL, " +
"blockchain_forks_json TEXT NOT NULL DEFAULT '[]', " +
"used_bytes BIGINT NOT NULL, " +
"last_block_number INTEGER NOT NULL, " +
"last_block_hash TEXT NOT NULL, " +
@@ -872,6 +887,7 @@ public final class PostgresStorageRepository
" OR normalized_login <> LOWER(BTRIM(login))"
);
statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS blockchain_forks_json TEXT NOT NULL DEFAULT '[]'");
statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''");
statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''");
@@ -74,7 +74,8 @@ public final class PeriodicBlockchainSyncService {
}
private static boolean isEnabled() {
return AppConfig.getInstance().getBoolean("blockchain.sync.enabled", true);
// PDA 1.2 удаляет sync_servers: пользовательские блокчейны синхронизируются через Arweave.
return false;
}
private static void runCycleSafe() {
@@ -22,54 +22,11 @@ public final class SyncServersBootstrapService {
private SyncServersBootstrapService() {}
public static void refreshFromSolanaOrLog() {
String serverLogin = normalize(AppConfig.getInstance().getParam(CONFIG_KEY));
if (serverLogin == null) {
log.warn("Sync bootstrap skipped: параметр {} не задан", CONFIG_KEY);
return;
}
try {
SolanaUserPdaImportService.ParsedServerProfile own =
SolanaUserPdaImportService.fetchServerProfileByLogin(serverLogin);
if (own == null) {
log.warn("Sync bootstrap skipped: server PDA не найдена для login={}", serverLogin);
return;
}
if (!own.isServer()) {
log.warn("Sync bootstrap skipped: PDA login={} не помечена как server", serverLogin);
return;
}
List<SyncServerEntry> entries = new ArrayList<>();
long now = System.currentTimeMillis();
for (String partnerLogin : own.syncServers()) {
String normalizedPartnerLogin = normalize(partnerLogin);
if (normalizedPartnerLogin == null) continue;
SolanaUserPdaImportService.ParsedServerProfile partner =
SolanaUserPdaImportService.fetchServerProfileByLogin(normalizedPartnerLogin);
if (partner == null) {
log.warn("Sync bootstrap: partner PDA не найдена для login={}", normalizedPartnerLogin);
continue;
}
if (!partner.isServer()) {
log.warn("Sync bootstrap: partner login={} не является server PDA", normalizedPartnerLogin);
continue;
}
String serverAddress = safe(partner.serverAddress());
if (serverAddress.isBlank()) {
log.warn("Sync bootstrap: у partner login={} пустой server_address", normalizedPartnerLogin);
continue;
}
entries.add(new SyncServerEntry(normalizedPartnerLogin, serverAddress, now));
}
SyncServersDAO.getInstance().replaceAll(entries);
log.info("Sync bootstrap: сохранено {} серверов синхронизации для login={}", entries.size(), serverLogin);
SyncServersDAO.getInstance().replaceAll(List.of());
log.info("Legacy sync_servers disabled: blockchain synchronization uses Arweave");
} catch (Exception e) {
log.error("Sync bootstrap failed while loading server PDA and sync_servers from Solana", e);
log.warn("Failed to clear legacy sync_servers table", e);
}
}
@@ -28,11 +28,11 @@ solana.users.sync.pollIntervalSeconds=300
sync.importUserProfileFromPartner.enabled=false
# ------------------------------------------------------------
# Прямая межсерверная синхронизация пользовательских блокчейнов
# через sync_servers: ListBlockchainHeads/GetBlockchainBlock -> локальный AddBlock.
# Для проверки режима Solana + Arweave без прямых связей серверов можно выключить.
# Legacy peer-to-peer blockchain sync. В PDA 1.2 sync_servers удалён,
# пользовательские блокчейны синхронизируются через Arweave.
# Параметр оставлен временно для совместимости конфигураций и не активирует legacy sync.
# ------------------------------------------------------------
blockchain.sync.enabled=true
blockchain.sync.enabled=false
# ------------------------------------------------------------
# Server public info
@@ -138,14 +138,11 @@ test.freeAvatar.walletJwkPath=
# ============================================================
# Arweave per-user-block transport (ANS-104)
# Test namespace: each user DataItem is signed with App=test5590.
# Channel DataItems additionally contain c_test5590=<canonical-channel-slug>.
# publish.mode: turbo | arweave | none
# Channel DataItems additionally contain c=<canonical-channel-slug>.
# ============================================================
arweave.blocks.publish.mode=none
arweave.blocks.publish.enabled=false
arweave.blocks.publish.intervalMinutes=15
arweave.blocks.publish.maxItems=10000
# Direct Arweave L1 fallback: server combines user DataItems into one standard ANS-104 bundle.
arweave.blocks.publish.maxBundleBytes=134217728
arweave.blocks.publish.gateway=https://arweave.net
arweave.blocks.publish.walletJwkPath=
@@ -153,18 +150,10 @@ arweave.blocks.publish.minConfirmations=0
arweave.blocks.publish.confirmPollSeconds=30
arweave.blocks.publish.confirmTimeoutMinutes=180
# Turbo: uploads each already user-signed DataItem separately, without re-signing it.
# paidByAddress is the public Turbo payer address. If it is empty and turbo.walletJwkPath is set,
# the Arweave payer address is derived locally from that JWK. The private key is never sent to Turbo.
# For paid uploads of someone else's signed DataItem, Turbo Credit Share Approval must exist for its signer.
arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx
arweave.blocks.publish.turbo.paidByAddress=
arweave.blocks.publish.turbo.walletJwkPath=
arweave.blocks.sync.enabled=false
arweave.blocks.sync.intervalMinutes=15
arweave.blocks.sync.gateway=https://turbo-gateway.com
arweave.blocks.sync.pageSize=100
arweave.blocks.sync.queueBatchSize=10000
arweave.blocks.sync.startBlockHeight=0
arweave.blocks.sync.maxDataItemBytes=8388608
arweave.blocks.sync.maxRootBundleBytes=268435456
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.13.0
server.version=1.11.0
client.version=1.14.0
server.version=1.12.0
+9 -3
View File
@@ -104,6 +104,12 @@ cp /path/to/SHiNE-product/application.properties ./application.properties
## Что пока остаётся как есть
- `sync_servers` сервер по-прежнему загружает из server PDA в Solana;
- runtime-сервер уже работает только с PostgreSQL;
- дальнейшим отдельным шагом остаются зачистка legacy-документации, переименования и перенос оставшихся прямых SQL-запросов в DAO/service.
- runtime-сервер работает с PostgreSQL;
- межсерверная доставка DM остаётся отдельным механизмом и не связана с blockchain sync;
- прямой blockchain sync через `sync_servers` отключён: пользовательские блоки синхронизируются через Arweave.
## Совместимость с user PDA 1.2
После обновления `shine_users` серверный модуль `shine-server-solana-users-sync` должен обновляться вместе с программой: текущий codec принимает только PDA 1.2. Legacy PDA 1.0 не мигрируются; тестовые legacy-записи можно закрыть временной инструкцией `close_legacy_pda`.
Миграция PostgreSQL v25 добавляет `blockchain_forks_json`. Старые compatibility-колонки продолжают содержать активный (последний) fork, а полный список ключей fork сохраняется отдельно и используется для поиска владельца Arweave-записей по любому историческому blockchain key.
+1 -1
View File
@@ -72,7 +72,7 @@ sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
## Operational-нюанс
Общий Helius devnet endpoint тоже может начать ограничивать запросы, если несколько инстансов одновременно делают тяжёлый bootstrap. Если после деплоя какой-то инстанс не подтянул `sync_servers` или завис на startup sync, рестартовать сервисы по одному с паузой.
Общий Helius devnet endpoint тоже может начать ограничивать запросы, если несколько инстансов одновременно делают тяжёлый bootstrap. Если после деплоя какой-то инстанс завис на bootstrap Arweave/Solana sync, рестартовать сервисы по одному с паузой.
Отдельный нюанс по web push:
+1 -1
View File
@@ -55,7 +55,7 @@
- физическое соединение создаётся одно на `serverLogin`;
- логические операции DM, settings и blockchain используют один WSS;
- завершение `RemoteSyncSession` не закрывает физический сокет;
- известные peer берутся из `sync_servers` и первых действующих маршрутов
- известные peer берутся из актуальных server PDA/access-server маршрутов
`user_access_servers_current`;
- список перечитывается каждые 30 секунд;
- при изменении URL соединение пересоздаётся;
+3 -4
View File
@@ -100,8 +100,7 @@ homeserver.key:home-a -> ключ A
homeserver.key:home-b -> ключ B
```
Публичный ключ homeserver-а публикуется в `SessionsBlock` пользовательской PDA как
`session_pub_key` с `session_type = 100`, имя — в `session_name` (формат PDA §13).
В PDA 1.2 `SessionsBlock` удалён, поэтому homeserver-ключи больше не публикуются через user PDA. Сама детерминированная деривация ключа сохранена как отдельный механизм устройства; способ его будущей публикации/авторизации определяется отдельно от PDA 1.2.
> Это переименование прежней схемы `subserver.key:<имя>` → `homeserver.key:<имя>`.
> Термин «саб-сервер» по проекту заменяется на «homeserver».
@@ -130,8 +129,8 @@ homeserver.key:home-b -> ключ B
- старый тестовый вариант; оставлен как legacy-скетч для сравнения и диагностики.
### Формат PDA (куда попадают ключи)
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
— `RootKeyBlock` §6, `ClientKeyBlock` §7, `blockchain_public_key` §9, `SessionsBlock`/`session_type=100` §13, оплата §2.1.
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.2.md`
— `RootKeyBlock`, `ClientKeyBlock`, append-only `BlockchainRegistryBlock` и экономика лимита. `SessionsBlock` в PDA 1.2 отсутствует.
### Сервер (тестовый seed)
- `SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java` `deriveKeysFromPassword` (~246) —
@@ -14,11 +14,10 @@
- сервер проверяет формат, пользователей и подпись до сохранения;
- повторная доставка одной ревизии идемпотентна;
- более старая ревизия не заменяет новую;
- у каждого пользователя действует только access_servers[0];
- дополнительные элементы старой PDA игнорируются без fallback;
- PDA 1.2 допускает максимум один access server; если он задан, используется единственная запись;
- старые PDA 1.0 не участвуют в текущем runtime-протоколе;
- DM и настройки не реплицируются между access-серверами одного пользователя;
- sync_servers серверного PDA используются только для синхронизации
пользовательских блокчейнов.
- пользовательские blockchain синхронизируются через Arweave; `sync_servers` в PDA 1.2 отсутствует.
## 3. Типы DM
@@ -567,3 +567,20 @@ SYNC_POLL_INTERVAL_SECONDS=300
7. добавить запись в `current` и `history`;
8. добавить periodic guard раз в 5 минут;
9. сохранить отдельный `main` для запуска как процесса.
## PDA 1.2 (2026-09-25)
Модуль синхронизации принимает только текущую PDA 1.2. Legacy PDA 1.0 не проецируются в runtime-state и не мигрируются: их можно закрыть отдельной временной инструкцией `close_legacy_pda`.
Для PDA 1.2:
- `RecoveryKeyBlock`, sessions, trusted state, archive head и `sync_servers` отсутствуют;
- `BlockchainRegistryBlock` содержит append-only список fork в порядке `blockchain_key + created_at_ms + paid_limit_bytes`;
- `ServerProfileBlock` в 1.2 допускает один адрес сервера, `AccessServersBlock` — 0 или 1 access server;
- в compatibility SQL-поля проецируется **последний** fork как активный `blockchain_key/paid_limit_bytes`;
- полный список fork сохраняется в `blockchain_forks_json`, поэтому поиск пользователя по старому blockchain key остаётся возможным;
- `blockchain_name` для compatibility view вычисляется как `<normalized_login>-NNN`, где `NNN` соответствует индексу fork + 1;
- удалённые tip-поля (`used_bytes`, `last_block_*`, Arweave tx id) в PDA 1.2 больше не являются источником истины и в compatibility snapshot заполняются нейтральными значениями;
- server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес.
Create/update транзакции больше не реконструируются байт-в-байт из instruction args. После обнаружения изменения sync-модуль перечитывает фактическую текущую PDA через Solana RPC и декодирует её. Это исключает дублирование on-chain сериализации. `close_legacy_pda` не создаёт новое состояние PDA и для runtime-sync не является пользовательским update.
+14 -506
View File
@@ -1,512 +1,20 @@
# Solana user_pda: итоговый целевой формат пользовательской записи
# SHiNE User PDA
Документ описывает целевой формат пользовательской PDA-записи `user_pda` для Solana-программы `shine_users`.
Актуальный формат: **1.2**.
Это не формат основного блокчейна SHiNE и не документация по `AddBlock`. Основной блокчейн SHiNE описан отдельно в `docs/Blockchain/`.
Каноническая спецификация находится в:
Статус документа: итоговый согласованный формат, к которому приведены `create_user_pda`, `update_user_pda` и тестовый сериализатор Solana-модуля.
`shine-solana/shine/doc/formats/shine-user-pda-format-v.1.2.md`
## 1. Назначение user_pda
Ключевые отличия от тестового 1.0:
`user_pda` хранит публичное состояние пользователя в Solana:
- удалены Recovery/Sessions/Trusted/ArchiveHead и `sync_servers`;
- root — cold recovery authority;
- последний blockchain key — обычный authority;
- BlockchainRegistry хранит append-only fork history: `key[32] + created_at_ms:u64 + paid_limit_bytes:u32`;
- ServerProfile допускает один адрес в 1.2, AccessServers — 0/1 сервер;
- variable blocks имеют `payload_len:u16` для forward compatibility;
- create/update работают только с 1.2;
- legacy 1.0 можно только временно закрыть `close_legacy_pda` и вернуть lamports caller-у.
- логин пользователя;
- неизменяемые параметры создания записи;
- публичный recovery-ключ пользователя;
- корневой публичный ключ пользователя;
- клиентский публичный ключ пользователя;
- данные одного или нескольких пользовательских блокчейнов SHiNE;
- серверные данные пользователя, если пользователь выступает сервером;
- серверы доступа пользователя;
- счетчики/лимиты;
- подпись записи.
На первом этапе поддерживается один пользовательский блокчейн SHiNE, но формат блока блокчейна сразу допускает повторение таких блоков в будущем.
## 2. Адрес PDA
Адрес пользовательской PDA вычисляется по логину:
- seed prefix: `user_login=`;
- второй seed: нормализованный логин в нижнем регистре;
- program id: программа `shine_users`.
Один логин соответствует одной `user_pda`.
## 2.1. Кто оплачивает create/update PDA
- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `client_key`.
- `root_key` используется для подписи unsigned части записи через Ed25519 instruction и не является fee payer.
- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `client_key`.
## 3. Общие правила кодирования
- Числа кодируются в Little Endian.
- `u8`, `u16`, `u32`, `u64` имеют обычный фиксированный размер.
- Публичный ключ Solana/Ed25519: 32 байта.
- Ed25519-подпись: 64 байта.
- SHA-256/Solana hash: 32 байта.
- Строка переменной длины: `len: u8` + `bytes[len]` в UTF-8.
- Arweave `tx_id`: строка переменной длины. Ожидаемая практическая длина base64url tx id - 43 байта, но формат хранит длину явно.
- Все типизированные блоки после фиксированного заголовка начинаются с `block_type: u8` и `block_version: u8`.
- Отдельный `block_len` у типизированных блоков не хранится: блоки парсятся по известным полям, счетчикам и строкам с `len: u8`.
## 4. Верхний формат записи
Первые 9 полей фиксированы и идут строго в указанном порядке. Это общий заголовок записи.
| N | Поле | Тип | Размер | Правило |
|---|------|-----|--------|---------|
| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. |
| 2 | `format_major` | `u8` | 1 | Для первого формата: `1`. |
| 3 | `format_minor` | `u8` | 1 | Для первой версии нового формата: `0`. |
| 4 | `record_len` | `u16` | 2 | Длина полезной записи от `magic` до `signature` включительно, без padding. |
| 5 | `created_at_ms` | `u64` | 8 | Время создания записи, Unix time в миллисекундах. Не меняется. |
| 6 | `updated_at_ms` | `u64` | 8 | Время последнего обновления записи. |
| 7 | `record_number` | `u32` | 4 | Номер версии записи пользователя. При создании `0`, при обновлении +1. |
| 8 | `prev_record_hash` | bytes | 32 | Хэш unsigned-части предыдущей записи. При создании 32 нулевых байта. |
| 9 | `login` | string | `1 + len` | Логин пользователя. Не меняется. |
После первых 9 полей идет набор типизированных блоков:
```text
UserPdaRecordV1
- fixed_header: поля 1..9
- blocks_count: u8
- blocks: TypedBlock[blocks_count]
- signature: [u8; 64]
- padding: bytes до размера PDA, если нужен
```
`blocks_count` входит в unsigned-часть записи и подписывается.
## 5. Типы блоков
Зарезервированные значения `block_type`:
| block_type | Блок | Назначение |
|------------|------|------------|
| `0` | `RecoveryKeyBlock` | Ключ восстановления пользователя. |
| `1` | `RootKeyBlock` | Корневой ключ пользователя. |
| `2` | `ClientKeyBlock` | Клиентский ключ пользователя. |
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
| `30` | `ServerProfileBlock` | Серверные данные пользователя. |
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. |
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `100` | `ArchiveHeadBlock` | Legacy/reserved. Новый per-block ANS-104 transport не использует это поле для пользовательской истории. |
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
Правила:
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`;
- каждый обязательный блок должен встречаться ровно один раз;
- порядок блоков в записи фиксируется для простоты проверки:
`RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`, `ArchiveHead`.
## 6. RecoveryKeyBlock
Recovery-ключ нужен для будущих сценариев восстановления и ротации остальных ключей. В текущей версии он только публикуется в записи и не меняется через обычный `update_user_pda`.
```text
RecoveryKeyBlock
- block_type: u8 = 0
- block_version: u8 = 0
- recovery_key: [u8; 32]
```
Правила:
- при создании задается публичный recovery-ключ пользователя;
- при обновлении `recovery_key` должен совпадать с предыдущей записью;
- приватный `recovery.key` в PDA не хранится;
- отдельная ротация recovery-ключа будет отдельным форматом/сценарием в будущем.
## 7. RootKeyBlock
Смена `root_key` пока не проектируется и не реализуется. Блок фиксирует только стадию `0`.
```text
RootKeyBlock
- block_type: u8 = 1
- block_version: u8 = 0
- root_key: [u8; 32]
```
Правила:
- при создании задается корневой публичный ключ пользователя;
- при обновлении `root_key` должен совпадать с предыдущей записью;
- ротация root-key будет отдельным форматом/сценарием в будущем.
## 8. ClientKeyBlock
Смена `client_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один клиентский ключ пользователя.
```text
ClientKeyBlock
- block_type: u8 = 2
- block_version: u8 = 0
- client_key: [u8; 32]
```
Правила:
- при создании задается текущий клиентский публичный ключ пользователя;
- при обновлении `client_key` должен совпадать с предыдущей записью;
- история устройств и несколько клиентских ключей в этом формате не хранятся.
## 9. BlockchainRegistryBlock
Блок хранит данные пользовательских блокчейнов SHiNE. Сейчас используется один блокчейн, но структура сразу сделана как список.
```text
BlockchainRegistryBlock
- block_type: u8 = 3
- block_version: u8 = 0
- blockchain_count: u8
- blockchain_records: BlockchainRecord[blockchain_count]
```
Правила:
- на первом этапе `blockchain_count = 1`;
- в будущем можно увеличить количество записей без изменения смысла `BlockchainRecord`;
- каждый `BlockchainRecord` описывает один пользовательский SHiNE-блокчейн.
## 10. BlockchainRecord
```text
BlockchainRecord
- blockchain_type: u8
- blockchain_name: string
- blockchain_public_key: [u8; 32]
- paid_limit_bytes: u64
- used_bytes: u64
- last_block_number: u32
- last_block_hash: [u8; 32]
- last_block_signature: [u8; 64]
- arweave_present: u8
- arweave_tx_id: string, только если arweave_present = 1
```
`blockchain_type`:
| Значение | Смысл |
|----------|-------|
| `1` | Основной пользовательский SHiNE-блокчейн. |
Поля:
- `blockchain_name` - строковое имя пользовательского блокчейна, например `login-001`. На первом этапе для основного блокчейна пользователя используется имя вида `<login>-001`, потому что это первый блокчейн этого пользователя.
- `blockchain_public_key` - публичный ключ блокчейна пользователя.
- `paid_limit_bytes` - оплаченный лимит хранения/записей в байтах.
- `used_bytes` - сколько байт уже занято в пользовательском SHiNE-блокчейне.
- `last_block_number` - номер последнего известного блока пользовательского блокчейна.
- `last_block_hash` - хэш последнего известного блока.
- `last_block_signature` - подпись хэша специального сообщения о вершине блокчейна ключом `blockchain_public_key`.
- `arweave_present` - `0`, если ссылки нет; `1`, если ссылка есть.
- `arweave_tx_id` - Arweave transaction id, где лежит выгруженный пользовательский канал/состояние.
Arweave `tx_id` - обычное поле внутри записи конкретного блокчейна. Solana-программа не проверяет, что такой Arweave transaction действительно существует и содержит корректные данные; это ответственность клиента/сервера/пользователя.
## 11. Правила обновления BlockchainRecord
При обновлении записи:
- `blockchain_type` для существующей записи не меняется;
- `blockchain_public_key` пока не ротируется автоматически; смена ключа требует отдельного согласованного сценария;
- `paid_limit_bytes` может только увеличиваться или оставаться прежним;
- при увеличении `paid_limit_bytes` пользователь платит комиссию в Solana по тарифам программы;
- `used_bytes` может только увеличиваться или оставаться прежним;
- `last_block_number` может только увеличиваться или оставаться прежним;
- `used_bytes <= paid_limit_bytes`;
- если `last_block_number` увеличился, то должны быть переданы новый `last_block_hash` и новая `last_block_signature`;
- `last_block_signature` проверяется через Ed25519-инструкцию Solana: подпись должна соответствовать хэшу сообщения `LastBlockState` и `blockchain_public_key`;
- в транзакции `create_user_pda` / `update_user_pda` две Ed25519-инструкции должны идти непосредственно перед вызовом `shine_users`: сначала подпись `root_key`, затем подпись `blockchain_public_key`;
- `arweave_tx_id` можно добавить или заменить на новый, если пользователь выгрузил более актуальное состояние в Arweave;
- уменьшать лимит, число блоков или занятый размер нельзя.
Сообщение `LastBlockState`, которое хэшируется и подписывается ключом `blockchain_public_key`:
```text
LastBlockState
- constant: bytes = "SHiNE_LAST_BLOCK"
- login: string
- blockchain_name: string
- last_block_number: u32
- last_block_hash: [u8; 32]
- used_bytes: u64
```
Алгоритм:
```text
message = SHA-256(LastBlockState bytes)
last_block_signature = Ed25519(blockchain_public_key, message)
```
Причина проверки подписи `LastBlockState`: `root_key` управляет Solana-записью пользователя, а `blockchain_public_key` подтверждает состояние конкретного пользовательского блокчейна. Подписывается не голый хэш, а связка логина, имени блокчейна, номера последнего блока, хэша последнего блока и занятого размера.
## 12. ServerProfileBlock
Блок присутствует, если пользователь выступает сервером.
```text
ServerProfileBlock
- block_type: u8 = 30
- block_version: u8 = 0
- is_server: u8
- address_format_type: u8, только если is_server = 1
- address_format_version: u8, только если is_server = 1
- server_address: string, только если is_server = 1
- sync_servers_count: u8, только если is_server = 1
- sync_servers: string[sync_servers_count], только если is_server = 1
```
Правила:
- `is_server = 0` означает, что серверных данных нет;
- `is_server = 1` означает, что пользователь публикует серверный профиль;
- `address_format_type` — тип формата адреса сервера: `1` = URL-строка (например `https://shineup.me/ws`);
- `address_format_version` — версия формата адреса, сейчас `0`;
- `sync_servers_count` максимум `32`;
- `server_address` - строковый адрес сервера в соответствии с `address_format_type`;
- `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует серверные данные и пользовательские блокчейны SHiNE. Это server-to-server список партнёров самого серверного узла, а не список серверов доставки личных сообщений для обычного пользователя. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы.
## 13. AccessServersBlock
Блок хранит серверы доступа/relay для пользователя.
```text
AccessServersBlock
- block_type: u8 = 40
- block_version: u8 = 0
- access_servers_count: u8
- access_servers: string[access_servers_count]
```
Правила:
- блок может отсутствовать, если серверы доступа не заданы;
- список может обновляться при изменении маршрутизации пользователя;
- `access_servers` — массив логинов серверов доступа/relay. Формат массива
сохранён для совместимости, но текущая клиентская и серверная логика использует
только первый элемент `access_servers[0]`;
- остальные элементы игнорируются без fallback;
- официальный UI записывает ровно один сервер и позволяет только заменить его;
- Solana-программа не обязана проверять, что логин действительно зарегистрирован
как сервер.
## 14. SessionsBlock
Блок хранит опубликованные пользовательские сессии. На текущем этапе регистрация пользователя не добавляет туда записи автоматически, поэтому стандартный create/update продолжает работать с пустым списком.
```text
SessionsBlock
- block_type: u8 = 50
- block_version: u8 = 0
- sessions_mode: u8
- sessions_count: u8
- sessions: SessionRecord[sessions_count]
```
`sessions_mode`:
| Значение | Смысл |
|----------|-------|
| `1` | Можно использовать и сессии, зарегистрированные в PDA, и сессии, созданные вне PDA. |
| `10` | Зарезервировано на будущее: можно использовать только сессии, опубликованные в PDA. |
Сейчас рабочий режим по умолчанию: `sessions_mode = 1`. Серверная логика пока не реализует особое поведение для `10`; это задел под будущее расширение.
```text
SessionRecord
- session_type: u8
- session_version: u8
- session_name: string
- session_pub_key: [u8; 32]
```
`session_type`:
| Значение | Смысл |
|----------|-------|
| `1` | Обычная пользовательская сессия. |
| `50` | Кошелёк пользователя. |
| `100` | Homeserver пользователя. |
Правила:
- максимум `64` записей на пользователя;
- `session_name` не пустой, максимум `64` байта;
- `session_name` может содержать только символы `[A-Za-z0-9_]`;
- `session_version` сейчас должна быть равна `1`;
- внутри одного блока должны быть уникальны и `session_name`, и `session_pub_key`;
- на текущем этапе UI и регистрация не обязаны добавлять туда записи автоматически.
## 15. TrustedStateBlock
Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик.
```text
TrustedStateBlock
- block_type: u8 = 70
- block_version: u8 = 0
- trusted_count: u8 = 0
```
Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат.
## 15.1. ArchiveHeadBlock
Необязательный блок текущей головы серверного архива. Он используется archive-capable сервером и хранится в том же User PDA.
```text
ArchiveHeadBlock
- block_type: u8 = 100
- block_version: u8 = 0
- archive_tx_id: [u8; 32]
- archive_hash: [u8; 32]
```
Семантика:
- `archive_tx_id` — legacy/reserved поле старой archive-head схемы; новый per-block ANS-104 transport его не обновляет;
- `archive_hash` — SHA-256 большого archive block по правилам `docs/Archive/01_PROTOCOL_v1.0.md`;
- отсутствие block `100` означает, что аккаунт ещё не объявлял archive head;
- обычный legacy `update_user_pda`, в instruction которого archive extension отсутствует, **обязан сохранить существующий ArchiveHeadBlock без изменений**;
- расширенный `update_user_pda` может заменить archive head или явно очистить его; отдельной Solana instruction для архива нет.
`ArchiveHeadBlock` входит в unsigned bytes User PDA и тем самым покрывается обычной root-подписью записи.
## 16. Подпись user_pda
Подписывается не вся PDA целиком, а unsigned-часть записи:
- от `magic` до последнего байта последнего типизированного блока включительно;
- включая `record_len`, `blocks_count`, все заголовки блоков и тела блоков;
- без поля `signature`;
- без padding.
Алгоритм:
```text
message = hash(unsigned_record_bytes)
signature = Ed25519(root_key, message)
```
Solana-программа проверяет подпись через встроенную Ed25519-инструкцию. Подписантом должен быть `root_key` из `RootKeyBlock`.
Для `shine_users` эта инструкция должна стоять в транзакции сразу перед Ed25519-инструкцией `last_block_signature` и непосредственно перед самой `create/update`-инструкцией программы.
Смену формата подписи сейчас не трогаем.
## 17. Регистрация пользователя
При регистрации:
- PDA еще не должна существовать;
- логин проходит проверку формата и login guard;
- `record_number = 0`;
- `prev_record_hash = 0x00...00`;
- `created_at_ms = updated_at_ms`;
- обязательные блоки присутствуют;
- создается минимум один `BlockchainRecord`;
- новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`;
- `ArchiveHeadBlock` при регистрации не обязателен; обычный пользователь/сервер может начать публиковать архив позже;
- стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит;
- `used_bytes <= paid_limit_bytes`;
- пользователь платит регистрационную комиссию;
- если покупается дополнительный лимит, пользователь платит комиссию за этот лимит;
- вся unsigned-часть записи подписана `root_key`.
## 18. Обновление пользователя
При обновлении:
- PDA должна существовать;
- `login`, `created_at_ms`, `recovery_key`, `root_key`, `client_key` не меняются;
- `record_number = previous_record_number + 1`;
- `prev_record_hash` равен хэшу unsigned-части предыдущей записи;
- `updated_at_ms` обновляется;
- unsigned-часть новой записи подписана `root_key`;
- если archive extension в instruction отсутствует (legacy client), старый `ArchiveHeadBlock` сохраняется; если extension присутствует, применяется переданное `archive_head_update`;
- лимиты блокчейнов могут только увеличиваться;
- занятый размер и номер последнего блока не могут уменьшаться;
- при увеличении оплаченного лимита пользователь доплачивает комиссию;
- Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует.
## 19. Отличия от старого линейного формата
Старый формат после `login` хранил поля линейно:
- `root_key_status`;
- `root_key`;
- `blockchain_key_status`;
- `blockchain_key`;
- `client_key_status`;
- `client_key`;
- `chain_number`;
- `balance`;
- серверные поля;
- access-серверы;
- `trusted_count`;
- `reserved`;
- `signature`.
Новый целевой формат сохраняет первые 9 фиксированных полей как заголовок, но дальше переходит на типизированные блоки:
- recovery-ключ становится отдельным обязательным блоком;
- ключи становятся отдельными блоками;
- данные блокчейна становятся расширенным блоком со своим публичным ключом, лимитом, занятым размером, вершиной цепочки и Arweave `tx_id`;
- серверные данные и access-серверы отделяются от данных блокчейна;
- расширение формата делается добавлением новых версий блоков или новых `block_type`, а не вставкой полей в середину линейной записи.
## 20. Деривация ключей из master secret
Сама Solana-программа не вычисляет ключи из секрета и не хранит приватные ключи. Но текущая согласованная клиентская схема деривации для публичной версии формата фиксируется здесь как reference для UI/ESP32/внешних клиентов.
Базовая формула:
```text
seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || suffix_utf8)
```
Где:
- `master_secret32` — 32-байтовый master secret пользователя;
- `suffix_utf8` — строка назначения ключа.
Согласованные suffix:
```text
"recovery.key"
"root.key"
"blockchain.key"
"client.key"
```
Соответствие:
```text
recovery.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "recovery.key")
root.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "root.key")
blockchain.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "blockchain.key")
client.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "client.key")
```
Далее каждая строка `seed` интерпретируется off-chain как `seed32` для отдельной пары Ed25519.
## 21. Что пока не входит в формат
Пока не проектируем:
- ротацию `recovery_key`;
- ротацию `root_key`;
- сложную ротацию `client_key`;
- ротацию `blockchain_public_key`;
- проверку содержимого Arweave transaction;
- хранение полной истории пользовательского блокчейна внутри Solana;
- подключение Solana-модуля к сборке/деплою основного сервера SHiNE.
Формат пользовательских SHiNE/Arweave блоков этим изменением не меняется.
+18 -141
View File
@@ -2,156 +2,33 @@
## Кратко
`shine_users` — вторая программа Solana-модуля SHiNE. Она отвечает за создание и обновление пользовательской PDA-записи, проверку подписи записи, проверку логина через `shine_login_guard` и оплату регистрации/дополнительного лимита.
`shine_users` создаёт и обновляет пользовательскую PDA SHiNE, проверяет authority/signature, логин через `shine_login_guard` и оплату регистрации/дополнительного лимита через `shine_payments`.
Папка программы: `shine-solana/shine/programs/shine_users/`.
Текущий формат пользовательской записи — **PDA 1.2**. Каноническая бинарная спецификация находится в:
## Текущие функции
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.2.md`
- `shine-solana/shine/doc/programs/shine_users.md`
1. `init_users_economy_config`
- Создает PDA с экономическими настройками пользователей.
- Записывает стартовую регистрационную комиссию, цену шага лимита и стартовый бонус лимита.
Эти документы являются источником истины; здесь фиксируется только архитектурное резюме.
2. `update_users_economy_config`
- Обновляет экономические настройки.
- Требует подпись `DAO_AUTHORITY` из общего deploy-конфига.
## PDA 1.2
3. `create_user_pda`
- Проверяет логин через `shine_login_guard`.
- Проверяет структуру полей пользователя.
- Проверяет подпись записи root-ключом пользователя.
- Создает `user_pda` по seed `login=<normalized_login>`.
- Переводит оплату регистрации и дополнительного лимита в `shine_payments::inflow_vault_pda`.
Основные данные:
4. `update_user_pda`
- Проверяет неизменяемые поля пользователя.
- Проверяет `prev_hash`, новую подпись и новое состояние последнего блока.
- При необходимости расширяет PDA.
- Переводит оплату дополнительного лимита в `shine_payments::inflow_vault_pda`.
- `RootKeyBlock` — cold recovery authority;
- `ClientKeyBlock` — клиентский/кошелёчный ключ;
- `BlockchainRegistryBlock` — append-only список fork: `blockchain_key[32] + created_at_ms:u64 + paid_limit_bytes:u32`; последний fork активен;
- необязательный `ServerProfileBlock` — в 1.2 ровно один адрес сервера;
- необязательный `AccessServersBlock` — в 1.2 максимум один access server.
## Аргументы инструкций
Из нового формата удалены `RecoveryKeyBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`, `sync_servers` и поля вершины пользовательского blockchain. Сами пользовательские блоки и их история живут вне PDA; долговременное хранение/синхронизация выполняется через Arweave.
`init_users_economy_config` аргументов не принимает.
Обычный update выполняется активным последним blockchain key и не может менять root. Root используется для recovery и может менять root. Внутри PDA сохраняются `record_number`, `prev_record_hash` и Ed25519-подпись нового состояния.
`update_users_economy_config`:
Новый обычный fork можно добавить не раньше чем через 72 часа после `created_at_ms` текущего fork; root-recovery может обойти cooldown. Клиентский timestamp принимается только в окне ±5 минут относительно Solana Clock.
- `registration_fee_lamports: u64`
- `lamports_per_limit_step: u64`
- `start_bonus_limit: u64`
Legacy PDA 1.0 не мигрируются. Для тестовых 1.0 аккаунтов предусмотрена временная инструкция `close_legacy_pda`, возвращающая lamports вызывающему signer.
`create_user_pda`:
- `login: String`
- `root_key: Pubkey`
- `created_at_ms: u64`
- `additional_limit: u64`
- `fields: UserMutableFields`
- `signature: Vec<u8>`
`update_user_pda`:
- `login: String`
- `root_key: Pubkey`
- `created_at_ms: u64`
- `updated_at_ms: u64`
- `version: u32`
- `prev_hash: Vec<u8>`
- `additional_limit: u64`
- `fields: UserMutableFields`
- `signature: Vec<u8>`
`UserMutableFields`:
- `client_key: Pubkey`
- `blockchain_public_key: Pubkey`
- `blockchain_name: String`
- `used_bytes: u64`
- `last_block_number: u32`
- `last_block_hash: Vec<u8>` — ровно 32 байта
- `last_block_signature: Vec<u8>` — ровно 64 байта
- `arweave_tx_id: String`
- `is_server: bool`
- `server_key: Pubkey`
- `server_address: String`
- `sync_servers: Vec<String>`
- `access_servers: Vec<String>` — формат остаётся массивом, но runtime
использует только первый элемент
- `trusted_count: u8`
## Главные PDA
1. `user_pda`
- PDA записи пользователя.
- Seed: `login=<normalized_login>`.
- Создается отдельно для каждого логина.
- Стартовый размер: `768` байт.
- При обновлении может расширяться через `realloc`, но один auto-realloc ограничен `10_000` байт.
2. `users_economy_config_pda`
- PDA с настройками экономики.
- Seed: `shine_users_economy_config`.
- Хранит регистрационную комиссию, цену шага лимита и стартовый бонус.
- Размер PDA: `8 + 96` байт.
## Текущие параметры экономики
Параметры initial config из `programs/shine_users/src/settings.rs`:
| Поле | Значение | Смысл |
| --- | --- | --- |
| `START_REGISTRATION_FEE_LAMPORTS` | `10_000_000` | стартовая комиссия регистрации, 0.01 SOL |
| `LIMIT_STEP` | `10_000` | шаг `additional_limit` |
| `START_LAMPORTS_PER_LIMIT_STEP` | `100_000` | 0.0001 SOL за один шаг лимита |
| `START_BONUS_LIMIT` | `100_000` | стартовый бесплатный лимит при регистрации |
`additional_limit` в create/update должен быть кратен `LIMIT_STEP`.
## Связь с другими программами
`shine_users` зависит от:
- `shine_login_guard` — для проверки логина при создании пользователя;
- `shine_payments` — для вычисления и проверки `inflow_vault_pda`, куда уходят платежи.
`create_user_pda` делает CPI-вызов `shine_login_guard::classify_login` и принимает только результат `0`. Premium/trademark логины сейчас отклоняются ошибками `PremiumLogin` или `TrademarkLoginRequiresReview`.
Подпись `user_pda` и подпись состояния последнего блока проверяются через встроенную Solana Ed25519-инструкцию, которая должна идти раньше инструкции `shine_users` в той же транзакции.
## Деньги
Деньги из `shine_users` идут только в `inflow_vault_pda` программы `shine_payments`.
Потоки:
- `create_user_pda`: регистрационная комиссия + оплата `additional_limit`;
- `update_user_pda`: оплата `additional_limit`, если она больше нуля.
## Ключи и управление
На старте удобно считать, что у программы есть отдельный управляющий ключ `key_2`.
Целевая модель:
- economy-настройки меняет DAO-authority;
- upgrade-authority программы после проверки передается DAO;
- пользовательские операции `create_user_pda` и `update_user_pda` остаются доступными обычным пользователям при корректных подписях и оплате.
## ArchiveHeadBlock (legacy/reserved)
Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`):
```text
archive_tx_id [32]
archive_hash [32]
```
Поле `ArchiveHeadBlock` осталось в Solana/PDA как legacy/reserved для совместимости формата PDA. Новый transport пользовательских блоков не использует server-level SHINE-ARCHIVE или archive head: каждый пользовательский block публикуется как ANS-104 DataItem внутри стандартных bundles.
Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим:
- legacy payload без archive extension сохраняет старый block `100`;
- новый payload может заменить/очистить archive head;
- итоговая полная User PDA запись, включая block `100`, покрывается обычной root-подписью.
Это позволяет обычным старым клиентским обновлениям профиля не стирать archive head серверного publisher-а.
## Экономика
Параметры экономики хранятся в `users_economy_config_pda`. `paid_limit_bytes` каждого fork — `u32`; старые fork-записи неизменяемы, пополняется только активный fork. Подробные формулы и ограничения описаны в документации программы.
+3
View File
@@ -544,6 +544,9 @@ export function render({navigate, chrome}) {
if (operationBusy) return;
const normalizedList = uniqueLogins(nextLogins);
if (normalizedList.length > 1) {
throw new Error('PDA 1.2 поддерживает только один сервер доступа.');
}
setOperationBusy(true);
target.textContent = String(inFlightText || 'Обновляем сервер доступа...');
let signingMaterial = null;
+1 -1
View File
@@ -333,7 +333,7 @@ export function render({ navigate }) {
const subtitle = document.createElement('p');
subtitle.className = 'meta-muted';
subtitle.textContent = 'Процесс запускается сразу: из этого секрета будут вычислены recovery key, root key, blockchain key и client key.';
subtitle.textContent = 'Процесс запускается сразу: из этого секрета будут вычислены root/recovery key, blockchain key и client key.';
const details = document.createElement('p');
details.className = 'meta-muted';
@@ -109,31 +109,17 @@ export function render({ navigate }) {
}
card.append(makeSecretField({ label: 'Главный секрет (master secret, base58, 32 байта)', value: secretB58 }));
// Recovery key
const recoverySep = document.createElement('p');
recoverySep.className = 'field-label';
recoverySep.textContent = 'Recovery key';
card.append(recoverySep);
card.append(makePublicField({
label: 'Recovery — публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.recoveryPair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Recovery — приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.recoveryPair.privatePkcs8B64)),
}));
// Root key
const rootSep = document.createElement('p');
rootSep.className = 'field-label';
rootSep.textContent = 'Root key';
rootSep.textContent = 'Root key (recovery)';
card.append(rootSep);
card.append(makePublicField({
label: 'Root — публичный (base58)',
label: 'Root / recovery — публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.rootPair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Root — приватный (seed base58, 32 байта)',
label: 'Root / recovery — приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64)),
}));
+1 -1
View File
@@ -30,7 +30,7 @@ export const REGISTRATION_FAQ_TOPICS = [
title: 'Как генерируются ключи и что делает пароль?',
paragraphs: [
'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.',
'Уже из этого секрета детерминированно строятся четыре основных ключа: recovery key, root key, blockchain key и client key.',
'Уже из этого секрета детерминированно строятся три основных ключа: root/recovery key, blockchain key и client key.',
'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.',
],
},
+1 -6
View File
@@ -1133,11 +1133,7 @@ export class AuthService {
});
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
if (onProgress) onProgress({ percent: 93, stage: 'derive', message: 'Вычисление recovery key...' });
const recoveryPair = await deriveEd25519FromMasterSecret(masterSecret, 'recovery.key');
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
if (onProgress) onProgress({ percent: 95, stage: 'derive', message: 'Вычисление root key...' });
if (onProgress) onProgress({ percent: 94, stage: 'derive', message: 'Вычисление root key...' });
const rootPair = await deriveEd25519FromMasterSecret(masterSecret, 'root.key');
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
@@ -1149,7 +1145,6 @@ export class AuthService {
const clientPair = await deriveEd25519FromMasterSecret(masterSecret, 'client.key');
const result = {
masterSecretB64: bytesToBase64(masterSecret),
recoveryPair,
rootPair,
blockchainPair,
clientPair,
File diff suppressed because it is too large Load Diff
+5 -14
View File
@@ -42,7 +42,7 @@
</div>
<h1>Регистрация серверного аккаунта</h1>
<p class="subtitle">Создаёт user_pda в Solana с флагом is_server=true</p>
<p class="subtitle">Создаёт серверную user_pda в Solana (PDA 1.2)</p>
<div class="card">
<h2>Параметры Solana</h2>
@@ -65,12 +65,8 @@
<input type="text" id="serverAddress" placeholder="Адрес сервера" />
</div>
<div class="field">
<label>Логины серверов c которыми будет синхронизировать записи в блокчейн этот сервер (Например shineupme)</label>
<textarea id="syncServers" placeholder="По одному логину на строку (можно оставить пустым)"></textarea>
</div>
<div class="field">
<label>Серверы доступа (access_servers, опционально)</label>
<textarea id="accessServers" placeholder="Обычно пусто для серверного PDA"></textarea>
<label>Сервер доступа (access_servers, 0 или 1)</label>
<textarea id="accessServers" placeholder="Один логин сервера доступа или пусто"></textarea>
</div>
</div>
@@ -99,17 +95,12 @@
<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-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="rootPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Blockchain Key — подпись LastBlockState</div>
<div class="kp-title">Blockchain Key — активный ключ блокчейна и обычный authority PDA</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="bchPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="bchPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
@@ -20,8 +20,6 @@ import { defaultServerAddress, defaultServerLogin, defaultSolanaEndpoint } from
const fieldMap = {
masterSecret: 'masterSecret',
recoveryPub: 'recoveryPub',
recoveryPriv: 'recoveryPriv',
rootPub: 'rootPub',
rootPriv: 'rootPriv',
bchPub: 'bchPub',
@@ -113,7 +111,6 @@ $('btnCreate').addEventListener('click', async () => {
login,
keyBundle,
serverAddress,
syncServers: parseLoginList($('syncServers').value),
accessServers: parseLoginList($('accessServers').value),
solanaEndpoint: endpoint,
});
+38 -67
View File
@@ -112,6 +112,7 @@ function renderParsed(parsed) {
renderRows([
{ label: 'PDA адрес', value: parsed.pdaAddress },
{ label: 'Формат', value: `${parsed.formatMajor}.${parsed.formatMinor}` },
{ label: 'Логин', value: parsed.login },
{ label: 'Статус', value: parsed.isServer ? 'server' : 'not server' },
{ label: 'recordNumber', value: String(parsed.recordNumber) },
@@ -126,11 +127,10 @@ function renderParsed(parsed) {
const badgeLine = $('badgeLine');
badgeLine.innerHTML = '';
const badges = [
{ label: 'format=1.2', kind: 'ok' },
{ 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: `forks=${Array.isArray(parsed.forks) ? parsed.forks.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');
@@ -141,84 +141,55 @@ function renderParsed(parsed) {
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', [
blocks.appendChild(renderBlock('RootKeyBlock', 'block_type=1, version=0', [
{ label: 'rootKey32', value: base58(parsed.rootKey) },
]));
blocks.appendChild(renderBlock('ClientKeyBlock', 'block_type=2', [
blocks.appendChild(renderBlock('ClientKeyBlock', 'block_type=2, version=0', [
{ 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({
const forkLines = (parsed.forks || []).map((fork, idx) =>
`#${idx}: ${base58(fork.blockchainKey)} · created=${formatTimestamp(fork.createdAtMs)} · limit=${formatBigInt(fork.paidLimitBytes)}`,
);
blocks.appendChild(renderBlock('BlockchainRegistryBlock', 'block_type=3, version=0', [
{ label: 'forkCount', value: String(parsed.forks?.length || 0) },
{ label: 'forks', value: forkLines.length ? forkLines.join('\n') : '—' },
]));
if (parsed.isServer) {
const addressLines = (parsed.serverAddresses || []).map((item) =>
`type=${item.addressFormatType}, v=${item.addressFormatVersion}: ${item.address}`,
);
blocks.appendChild(renderBlock('ServerProfileBlock', 'block_type=30, version=0', [
{ label: 'addressCount', value: String(parsed.serverAddresses?.length || 0) },
{ label: 'address', value: addressLines.length ? addressLines.join('\n') : '—' },
]));
}
if (Array.isArray(parsed.accessServers) && parsed.accessServers.length) {
blocks.appendChild(renderBlock('AccessServersBlock', 'block_type=40, version=0', [
{ label: 'accessServersCount', value: String(parsed.accessServers.length) },
{ label: 'accessServer', value: renderArrayLine(parsed.accessServers) },
]));
}
const raw = {
pdaAddress: parsed.pdaAddress,
format: `${parsed.formatMajor}.${parsed.formatMinor}`,
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),
prevRecordHash: hex(parsed.prevRecordHash),
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);
serverAddresses: (parsed.serverAddresses || []).map((a) => ({ ...a })),
accessServers: parsed.accessServers || [],
signature: base58(parsed.signature),
};
$('rawJson').textContent = JSON.stringify(raw, null, 2);
}
$('btnLoad').addEventListener('click', async () => {
+2 -29
View File
@@ -111,18 +111,6 @@ async function pairFromSeedBase58(seedB58, explicitPubB58) {
export async function buildKeyBundleFromForm(fieldMap, options = {}) {
const requireBlockchain = options.requireBlockchain !== false;
let recovery = null;
const masterSecretValue = fieldMap.masterSecret ? String($(fieldMap.masterSecret).value || '').trim() : '';
if (masterSecretValue) {
const masterSecret32 = ensure32Bytes(base58ToBytes(masterSecretValue));
const recoveryPair = await deriveEd25519FromMasterSecret(masterSecret32, 'recovery.key');
recovery = {
publicKeyB64: recoveryPair.publicKeyB64,
privatePkcs8B64: recoveryPair.privatePkcs8B64,
publicKeyB58: bytesToBase58(base64ToBytes(recoveryPair.publicKeyB64)),
privateSeedB58: bytesToBase58(base64ToBytes(recoveryPair.privatePkcs8B64).slice(-32)),
};
}
const root = await pairFromSeedBase58($(fieldMap.rootPriv).value, $(fieldMap.rootPub).value);
const device = await pairFromSeedBase58($(fieldMap.devPriv).value, $(fieldMap.devPub).value);
const blockchainPriv = String($(fieldMap.bchPriv).value || '').trim();
@@ -134,9 +122,6 @@ export async function buildKeyBundleFromForm(fieldMap, options = {}) {
}
return {
keyBundle: {
recoveryPair: recovery
? { publicKeyB64: recovery.publicKeyB64, privatePkcs8B64: recovery.privatePkcs8B64 }
: null,
rootPair: { publicKeyB64: root.publicKeyB64, privatePkcs8B64: root.privatePkcs8B64 },
blockchainPair: blockchain
? { publicKeyB64: blockchain.publicKeyB64, privatePkcs8B64: blockchain.privatePkcs8B64 }
@@ -144,8 +129,6 @@ export async function buildKeyBundleFromForm(fieldMap, options = {}) {
clientPair: { publicKeyB64: device.publicKeyB64, privatePkcs8B64: device.privatePkcs8B64 },
},
normalized: {
recoveryPubB58: recovery?.publicKeyB58 || '',
recoveryPrivB58: recovery?.privateSeedB58 || '',
rootPubB58: root.publicKeyB58,
rootPrivB58: root.privateSeedB58,
bchPubB58: blockchain?.publicKeyB58 || '',
@@ -165,20 +148,14 @@ export async function deriveKeyBundleFromPassword({ login, password, onProgress
login: cleanLogin,
onProgress,
});
const [recoveryPair, rootPair, blockchainPair, clientPair] = await Promise.all([
deriveEd25519FromMasterSecret(masterSecret32, 'recovery.key'),
const [rootPair, blockchainPair, clientPair] = await Promise.all([
deriveEd25519FromMasterSecret(masterSecret32, 'root.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'blockchain.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'client.key'),
]);
return {
masterSecret32,
keyBundle: {
recoveryPair,
rootPair,
blockchainPair,
clientPair,
},
keyBundle: { rootPair, blockchainPair, clientPair },
};
}
@@ -186,10 +163,6 @@ export function fillKeyFields(fieldMap, keyBundle, masterSecret32) {
if (masterSecret32) {
$(fieldMap.masterSecret).value = bytesToBase58(masterSecret32);
}
if (fieldMap.recoveryPub && fieldMap.recoveryPriv && keyBundle.recoveryPair) {
$(fieldMap.recoveryPub).value = bytesToBase58(base64ToBytes(keyBundle.recoveryPair.publicKeyB64));
$(fieldMap.recoveryPriv).value = bytesToBase58(base64ToBytes(keyBundle.recoveryPair.privatePkcs8B64).slice(-32));
}
$(fieldMap.rootPub).value = bytesToBase58(base64ToBytes(keyBundle.rootPair.publicKeyB64));
$(fieldMap.rootPriv).value = bytesToBase58(base64ToBytes(keyBundle.rootPair.privatePkcs8B64).slice(-32));
$(fieldMap.bchPub).value = bytesToBase58(base64ToBytes(keyBundle.blockchainPair.publicKeyB64));
@@ -10,7 +10,6 @@ import {
formatBigInt,
formatTimestamp,
openDevnetTopup,
parseLoginList,
publicKeyBytesToBase58,
refreshDeviceBalance,
setGenMessage,
@@ -26,8 +25,6 @@ import { defaultServerAddress, defaultServerLogin, defaultSolanaEndpoint } from
const fieldMap = {
masterSecret: 'masterSecret',
recoveryPub: 'recoveryPub',
recoveryPriv: 'recoveryPriv',
rootPub: 'rootPub',
rootPriv: 'rootPriv',
bchPub: 'bchPub',
@@ -42,7 +39,6 @@ let currentPda = null;
function resetExpectedKeysUi() {
$('expectedKeysBox').style.display = 'none';
setText('expectedRecoveryPub', '');
setText('expectedRootPub', '');
setText('expectedBchPub', '');
setText('expectedDevPub', '');
@@ -52,7 +48,6 @@ function resetExpectedKeysUi() {
function renderExpectedKeys(parsed) {
$('expectedKeysBox').style.display = 'block';
setText('expectedRecoveryPub', publicKeyBytesToBase58(parsed.recoveryKey));
setText('expectedRootPub', publicKeyBytesToBase58(parsed.rootKey));
setText('expectedBchPub', publicKeyBytesToBase58(parsed.blockchain.blockchainPublicKey));
setText('expectedDevPub', publicKeyBytesToBase58(parsed.clientKey));
@@ -64,7 +59,6 @@ function compareCurrentFormKeysWithPda() {
const blockchainActual = String($('bchPub').value || '').trim();
return {
resultMap: {
recovery: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.recoveryKey), $('recoveryPub').value),
root: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.rootKey), $('rootPub').value),
blockchain: blockchainActual
? compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.blockchain.blockchainPublicKey), blockchainActual)
@@ -178,17 +172,15 @@ $('btnLoad').addEventListener('click', async () => {
$('iCreated').textContent = formatTimestamp(parsed.createdAtMs);
$('iUpdated').textContent = formatTimestamp(parsed.updatedAtMs);
$('iSrvAddr').textContent = parsed.serverAddress || '—';
$('iSync').textContent = parsed.syncServers.length ? parsed.syncServers.join(', ') : '—';
$('iBch').textContent = parsed.blockchain.blockchainName;
$('iLimit').textContent = formatBigInt(parsed.blockchain.paidLimitBytes);
$('password').value = '';
$('serverAddress').value = parsed.serverAddress || '';
$('syncServers').value = parsed.syncServers.join('\n');
$('pdaInfo').style.display = 'block';
$('updateForm').style.display = 'block';
renderExpectedKeys(parsed);
setStatus($('status'), 'PDA загружена. Можно менять адрес или sync_servers.', 'success');
setStatus($('status'), 'PDA загружена. Можно менять адрес сервера.', 'success');
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
} finally {
@@ -227,7 +219,6 @@ $('btnUpdate').addEventListener('click', async () => {
serverAddress,
addressFormatType: currentPda.addressFormatType ?? 1,
addressFormatVersion: currentPda.addressFormatVersion ?? 0,
syncServers: parseLoginList($('syncServers').value),
solanaEndpoint: endpoint,
});
+2 -16
View File
@@ -50,7 +50,7 @@
</div>
<h1>Обновление PDA сервера</h1>
<p class="subtitle">Меняет адрес сервера или список серверов синхронизации</p>
<p class="subtitle">Меняет адрес SHiNE-сервера и другие поля PDA 1.2</p>
<div class="card">
<h2>Параметры Solana</h2>
@@ -76,7 +76,6 @@
<div class="pda-row"><span class="pda-key">Создан</span><span class="pda-value" id="iCreated"></span></div>
<div class="pda-row"><span class="pda-key">Обновлён</span><span class="pda-value" id="iUpdated"></span></div>
<div class="pda-row"><span class="pda-key">Адрес сервера</span><span class="pda-value" id="iSrvAddr"></span></div>
<div class="pda-row"><span class="pda-key">sync_servers</span><span class="pda-value" id="iSync"></span></div>
<div class="pda-row"><span class="pda-key">Blockchain</span><span class="pda-value" id="iBch"></span></div>
<div class="pda-row"><span class="pda-key">Paid limit</span><span class="pda-value" id="iLimit"></span></div>
</div>
@@ -89,10 +88,6 @@
<label>Арес сервера (URL) например shineup.me</label>
<input type="text" id="serverAddress" placeholder="Адрес сервера" />
</div>
<div class="field">
<label>Логины серверов c которыми будет синхронизировать записи в блокчейн этот сервер (Например shineupme)</label>
<textarea id="syncServers" placeholder="По одному логину на строку (можно оставить пустым)"></textarea>
</div>
</div>
<div class="card">
@@ -120,12 +115,7 @@
<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-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="rootPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
@@ -150,10 +140,6 @@
<div class="expected-card" id="expectedKeysBox" style="display:none">
<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-lbl">Ожидаемый root public key</div>
<div class="expected-val" id="expectedRootPub"></div>
+137 -5
View File
@@ -1032,8 +1032,8 @@ select:focus {
/* Viewer v3 */
:root{--v3-bg:#f5f5f3;--v3-panel:#fff;--v3-text:#171717;--v3-sub:#747474;--v3-border:#e7e7e3;--v3-hover:#f0f0ed;--v3-shadow:0 18px 60px rgba(0,0,0,.08)}
html[data-theme="dark"]{color-scheme:dark;--v3-bg:#101110;--v3-panel:#181918;--v3-text:#f3f3ef;--v3-sub:#9c9d98;--v3-border:#292a28;--v3-hover:#222320;--v3-shadow:0 18px 60px rgba(0,0,0,.28)}
html,body{background:var(--v3-bg)!important;color:var(--v3-text)!important}.viewer-app{min-height:100vh;background:var(--v3-bg);padding-bottom:54px}.viewer-toolbar{position:sticky;top:0;z-index:30;height:64px;padding:0 max(18px,calc((100vw - 980px)/2));display:flex;align-items:center;gap:10px;background:color-mix(in srgb,var(--v3-bg) 88%,transparent);backdrop-filter:blur(18px);border-bottom:1px solid var(--v3-border);overflow:visible!important}
.v3-brand{font-weight:800;letter-spacing:-.03em;margin-right:auto}.v3-tabs{display:flex;padding:4px;background:var(--v3-hover);border-radius:12px}.v3-tab{border:0;background:transparent;color:var(--v3-sub);padding:8px 13px;border-radius:9px;font-weight:700;cursor:pointer}.v3-tab.is-active{background:var(--v3-panel);color:var(--v3-text);box-shadow:0 1px 5px rgba(0,0,0,.08)}.toolbar-icon{background:var(--v3-panel)!important;color:var(--v3-text)!important;border:1px solid var(--v3-border)!important;border-radius:11px!important;width:40px;height:40px}.v3-menu{position:absolute;right:max(18px,calc((100vw - 980px)/2));top:58px;width:min(340px,calc(100vw - 28px));padding:14px;background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:16px;box-shadow:var(--v3-shadow);z-index:1000}.v3-menu label{color:var(--v3-text)}.v3-menu input{background:var(--v3-bg);color:var(--v3-text);border-color:var(--v3-border)}.v3-switch{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 2px 14px}.viewer-main{width:min(860px,calc(100% - 28px));margin:22px auto!important}.mobile-card,.message-bubble,.channel-row{background:var(--v3-panel)!important;color:var(--v3-text)!important;border-color:var(--v3-border)!important;box-shadow:none!important}.message-bubble__meta,.channel-row__meta,.channel-row__preview,.channel-head p{color:var(--v3-sub)!important}.thread-toggle{border:0;background:transparent;color:var(--v3-sub);padding:8px 0 2px;font-weight:700;cursor:pointer}.thread-toggle:hover{color:var(--v3-text)}.message-thread__replies{border-left:1px solid var(--v3-border)!important;margin-left:18px!important;padding-left:16px!important}.v3-status{position:fixed;z-index:40;left:0;right:0;bottom:0;height:38px;display:flex;align-items:center;padding:0 max(18px,calc((100vw - 980px)/2));background:var(--v3-panel);border-top:1px solid var(--v3-border);font-size:12px;color:var(--v3-sub);cursor:pointer}.v3-status-detail{position:fixed;z-index:39;left:50%;bottom:38px;transform:translateX(-50%);width:min(760px,calc(100% - 28px));max-height:45vh;overflow:auto;background:var(--v3-panel);border:1px solid var(--v3-border);border-bottom:0;border-radius:16px 16px 0 0;padding:16px;box-shadow:var(--v3-shadow);font:12px/1.55 var(--mono);white-space:pre-wrap}.v3-start{max-width:520px;margin:12vh auto;padding:28px;background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:24px;box-shadow:var(--v3-shadow)}.v3-start h1{margin:0 0 8px;font-size:32px;letter-spacing:-.04em}.v3-start p{color:var(--v3-sub);line-height:1.5}.v3-start-grid{display:grid;gap:12px}.v3-start input{background:var(--v3-bg);color:var(--v3-text);border-color:var(--v3-border)}.v3-open{height:48px;border:0;border-radius:12px;background:var(--v3-text);color:var(--v3-bg);font-weight:800;cursor:pointer}.v3-blocks{display:grid;gap:8px}.v3-block{background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:14px;padding:12px 14px}.v3-block summary{cursor:pointer;font-weight:750}.v3-block pre{overflow:auto;color:var(--v3-sub);white-space:pre-wrap}.message-inline-media{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:10px}.message-inline-media:has(>a:only-child){grid-template-columns:minmax(0,1fr)}.message-inline-media a{display:block;overflow:hidden;border-radius:12px;border:1px solid var(--v3-border);background:var(--v3-bg)}.message-inline-media img{display:block;width:100%;max-height:520px;object-fit:contain;background:var(--v3-bg)}@media(max-width:520px){.message-inline-media{grid-template-columns:1fr}.message-inline-media img{max-height:70vh}}
html,body{background:var(--v3-bg)!important;color:var(--v3-text)!important}.viewer-app{min-height:100vh;background:var(--v3-bg);padding-bottom:54px}.viewer-toolbar{position:sticky;top:0;z-index:30;height:64px;padding:0 max(18px,calc((100vw - 980px)/2));display:flex;align-items:center;gap:10px;background:color-mix(in srgb,var(--v3-bg) 88%,transparent);backdrop-filter:blur(18px);border-bottom:1px solid var(--v3-border)}
.v3-brand{font-weight:800;letter-spacing:-.03em;margin-right:auto}.v3-tabs{display:flex;padding:4px;background:var(--v3-hover);border-radius:12px}.v3-tab{border:0;background:transparent;color:var(--v3-sub);padding:8px 13px;border-radius:9px;font-weight:700;cursor:pointer}.v3-tab.is-active{background:var(--v3-panel);color:var(--v3-text);box-shadow:0 1px 5px rgba(0,0,0,.08)}.toolbar-icon{background:var(--v3-panel)!important;color:var(--v3-text)!important;border:1px solid var(--v3-border)!important;border-radius:11px!important;width:40px;height:40px}.v3-menu{position:absolute;right:max(18px,calc((100vw - 980px)/2));top:58px;width:min(340px,calc(100vw - 28px));padding:14px;background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:16px;box-shadow:var(--v3-shadow);z-index:50}.v3-menu label{color:var(--v3-text)}.v3-menu input{background:var(--v3-bg);color:var(--v3-text);border-color:var(--v3-border)}.v3-switch{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 2px 14px}.viewer-main{width:min(860px,calc(100% - 28px));margin:22px auto!important}.mobile-card,.message-bubble,.channel-row{background:var(--v3-panel)!important;color:var(--v3-text)!important;border-color:var(--v3-border)!important;box-shadow:none!important}.message-bubble__meta,.channel-row__meta,.channel-row__preview,.channel-head p{color:var(--v3-sub)!important}.thread-toggle{border:0;background:transparent;color:var(--v3-sub);padding:8px 0 2px;font-weight:700;cursor:pointer}.thread-toggle:hover{color:var(--v3-text)}.message-thread__replies{border-left:1px solid var(--v3-border)!important;margin-left:18px!important;padding-left:16px!important}.v3-status{position:fixed;z-index:40;left:0;right:0;bottom:0;height:38px;display:flex;align-items:center;padding:0 max(18px,calc((100vw - 980px)/2));background:var(--v3-panel);border-top:1px solid var(--v3-border);font-size:12px;color:var(--v3-sub);cursor:pointer}.v3-status-detail{position:fixed;z-index:39;left:50%;bottom:38px;transform:translateX(-50%);width:min(760px,calc(100% - 28px));max-height:45vh;overflow:auto;background:var(--v3-panel);border:1px solid var(--v3-border);border-bottom:0;border-radius:16px 16px 0 0;padding:16px;box-shadow:var(--v3-shadow);font:12px/1.55 var(--mono);white-space:pre-wrap}.v3-start{max-width:520px;margin:12vh auto;padding:28px;background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:24px;box-shadow:var(--v3-shadow)}.v3-start h1{margin:0 0 8px;font-size:32px;letter-spacing:-.04em}.v3-start p{color:var(--v3-sub);line-height:1.5}.v3-start-grid{display:grid;gap:12px}.v3-start input{background:var(--v3-bg);color:var(--v3-text);border-color:var(--v3-border)}.v3-open{height:48px;border:0;border-radius:12px;background:var(--v3-text);color:var(--v3-bg);font-weight:800;cursor:pointer}.v3-blocks{display:grid;gap:8px}.v3-block{background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:14px;padding:12px 14px}.v3-block summary{cursor:pointer;font-weight:750}.v3-block pre{overflow:auto;color:var(--v3-sub);white-space:pre-wrap}.message-inline-media{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:10px}.message-inline-media:has(>a:only-child){grid-template-columns:minmax(0,1fr)}.message-inline-media a{display:block;overflow:hidden;border-radius:12px;border:1px solid var(--v3-border);background:var(--v3-bg)}.message-inline-media img{display:block;width:100%;max-height:520px;object-fit:contain;background:var(--v3-bg)}@media(max-width:520px){.message-inline-media{grid-template-columns:1fr}.message-inline-media img{max-height:70vh}}
.verification-view{position:fixed;inset:0;z-index:80;background:var(--v3-bg);color:var(--v3-text);overflow:auto;padding:0 0 70px}
.verification-head{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:12px;padding:14px max(16px,calc((100vw - 900px)/2));background:var(--v3-panel);border-bottom:1px solid var(--v3-border)}
.verification-head h2{margin:0;font-size:18px}.verification-body{width:min(900px,calc(100% - 28px));margin:20px auto;display:grid;gap:14px}
@@ -1553,7 +1553,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
};
}
function parseShineUserPda(dataBytes) {
function parseShineUserPdaLegacy(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
const reader = makeReader(bytes);
const magic = new TextDecoder().decode(reader.readBytes(5));
@@ -1717,6 +1717,138 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
};
}
function parseShineUserPdaV12(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
const reader = makeReader(bytes);
const magic = new TextDecoder().decode(reader.readBytes(5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA: magic не совпадает');
const formatMajor = reader.readU8();
const formatMinor = reader.readU8();
const recordLen = reader.readU16();
if (formatMajor !== 1 || formatMinor !== 2 || recordLen < 73 || recordLen > bytes.length) {
throw new Error('Некорректный формат PDA 1.2');
}
const createdAtMs = reader.readU64();
const updatedAtMs = reader.readU64();
const recordNumber = reader.readU32();
const prevRecordHash = reader.readBytes(32);
const login = reader.readStrU8();
const blocksCount = reader.readU8();
let rootKey = null;
let clientKey = null;
let forks = null;
let serverProfile = null;
let accessServers = [];
for (let index = 0; index < blocksCount; index += 1) {
const blockType = reader.readU8();
const blockVersion = reader.readU8();
if (blockType === BLOCK_TYPE_ROOT_KEY) {
if (blockVersion !== 0) throw new Error('Неизвестная версия RootKeyBlock');
rootKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_CLIENT_KEY) {
if (blockVersion !== 0) throw new Error('Неизвестная версия ClientKeyBlock');
clientKey = reader.readBytes(32);
continue;
}
const payloadLen = reader.readU16();
const payload = makeReader(reader.readBytes(payloadLen));
if (blockVersion !== 0) continue;
if (blockType === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
const count = payload.readU16();
if (count < 1) throw new Error('BlockchainRegistry 1.2 пуст');
forks = [];
for (let j = 0; j < count; j += 1) {
forks.push({
blockchainKey: payload.readBytes(32),
paidLimitBytes: BigInt(payload.readU32()),
});
}
} else if (blockType === BLOCK_TYPE_SERVER_PROFILE) {
const count = payload.readU8();
const addresses = [];
for (let j = 0; j < count; j += 1) {
addresses.push({
addressFormatType: payload.readU8(),
addressFormatVersion: payload.readU8(),
address: payload.readStrU8(),
});
}
const first = addresses[0] || null;
serverProfile = first ? {
blockVersion,
addressFormatType: first.addressFormatType,
addressFormatVersion: first.addressFormatVersion,
serverAddress: first.address,
addresses,
syncServers: [],
} : null;
} else if (blockType === BLOCK_TYPE_ACCESS_SERVERS) {
const count = payload.readU8();
if (count > 1) throw new Error('PDA 1.2 допускает максимум один access server');
accessServers = [];
for (let j = 0; j < count; j += 1) accessServers.push(payload.readStrU8());
}
}
if (!rootKey || !clientKey || !forks || forks.length < 1) {
throw new Error('В PDA 1.2 отсутствуют обязательные блоки shine_users');
}
const active = forks[forks.length - 1];
const blockchain = {
blockchainType: 1,
blockchainName: `${String(login).toLowerCase()}-${String(forks.length).padStart(3, '0')}`,
blockchainPublicKey: active.blockchainKey,
paidLimitBytes: active.paidLimitBytes,
usedBytes: 0n,
lastBlockNumber: 0,
lastBlockHash: new Uint8Array(32),
lastBlockSignature: new Uint8Array(64),
arweaveTxId: '',
};
const signature = bytes.slice(recordLen - 64, recordLen);
return {
formatMajor,
formatMinor,
recordLen,
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
login,
blocksCount,
recoveryKey: null,
rootKey,
clientKey,
forks,
blockchain,
serverProfile,
accessServers,
sessionsMode: DEFAULT_SESSIONS_MODE,
sessions: [],
trustedCount: 0,
signature,
};
}
function parseShineUserPda(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
if (bytes.length < 9) throw new Error('Повреждённый формат PDA');
const magic = new TextDecoder().decode(bytes.slice(0, 5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA: magic не совпадает');
const formatMajor = bytes[5];
const formatMinor = bytes[6];
if (formatMajor === 1 && formatMinor === 2) return parseShineUserPdaV12(bytes);
if (formatMajor === 1 && formatMinor === 0) return parseShineUserPdaLegacy(bytes);
throw new Error(`Неподдерживаемый формат PDA ${formatMajor}.${formatMinor}`);
}
async function digestSha256(bytes) {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
const hash = await crypto.subtle.digest('SHA-256', input);
@@ -2288,7 +2420,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
async function* queryArweaveChannelPages(gatewayBaseUrl, blockchainKey, channelName) {
const gateway = baseUrl(gatewayBaseUrl);
const ownerAddress = await arweaveOwnerAddressFromBlockchainKey(blockchainKey);
// Author-first and transport-agnostic (Turbo/direct Arweave): discover user DataItems by owner + App=test5590; channel/c_test5590 is only a local view filter.
// Author-first: channel is a local view filter, never an Arweave query filter.
const tags = [{ name: 'App', values: [SHINE_ARWEAVE_APP_TAG] }];
const query = `query($owners:[String!],$tags:[TagFilter!],$after:String){transactions(first:100,after:$after,owners:$owners,tags:$tags,sort:HEIGHT_DESC){pageInfo{hasNextPage}edges{cursor node{id owner{address key} block{height timestamp} bundledIn{id} tags{name value}}}}}`;
let after = null;
@@ -2476,7 +2608,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
signatureHex: bytesToHex(parsed.signature),
},
keys: {
recoveryKeyBase58: bytesToBase58(parsed.recoveryKey),
recoveryKeyBase58: parsed.recoveryKey ? bytesToBase58(parsed.recoveryKey) : '',
rootKeyBase58: bytesToBase58(parsed.rootKey),
clientKeyBase58: bytesToBase58(parsed.clientKey),
},
+135 -3
View File
@@ -1540,7 +1540,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
};
}
function parseShineUserPda(dataBytes) {
function parseShineUserPdaLegacy(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
const reader = makeReader(bytes);
const magic = new TextDecoder().decode(reader.readBytes(5));
@@ -1704,6 +1704,138 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
};
}
function parseShineUserPdaV12(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
const reader = makeReader(bytes);
const magic = new TextDecoder().decode(reader.readBytes(5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA: magic не совпадает');
const formatMajor = reader.readU8();
const formatMinor = reader.readU8();
const recordLen = reader.readU16();
if (formatMajor !== 1 || formatMinor !== 2 || recordLen < 73 || recordLen > bytes.length) {
throw new Error('Некорректный формат PDA 1.2');
}
const createdAtMs = reader.readU64();
const updatedAtMs = reader.readU64();
const recordNumber = reader.readU32();
const prevRecordHash = reader.readBytes(32);
const login = reader.readStrU8();
const blocksCount = reader.readU8();
let rootKey = null;
let clientKey = null;
let forks = null;
let serverProfile = null;
let accessServers = [];
for (let index = 0; index < blocksCount; index += 1) {
const blockType = reader.readU8();
const blockVersion = reader.readU8();
if (blockType === BLOCK_TYPE_ROOT_KEY) {
if (blockVersion !== 0) throw new Error('Неизвестная версия RootKeyBlock');
rootKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_CLIENT_KEY) {
if (blockVersion !== 0) throw new Error('Неизвестная версия ClientKeyBlock');
clientKey = reader.readBytes(32);
continue;
}
const payloadLen = reader.readU16();
const payload = makeReader(reader.readBytes(payloadLen));
if (blockVersion !== 0) continue;
if (blockType === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
const count = payload.readU16();
if (count < 1) throw new Error('BlockchainRegistry 1.2 пуст');
forks = [];
for (let j = 0; j < count; j += 1) {
forks.push({
blockchainKey: payload.readBytes(32),
paidLimitBytes: BigInt(payload.readU32()),
});
}
} else if (blockType === BLOCK_TYPE_SERVER_PROFILE) {
const count = payload.readU8();
const addresses = [];
for (let j = 0; j < count; j += 1) {
addresses.push({
addressFormatType: payload.readU8(),
addressFormatVersion: payload.readU8(),
address: payload.readStrU8(),
});
}
const first = addresses[0] || null;
serverProfile = first ? {
blockVersion,
addressFormatType: first.addressFormatType,
addressFormatVersion: first.addressFormatVersion,
serverAddress: first.address,
addresses,
syncServers: [],
} : null;
} else if (blockType === BLOCK_TYPE_ACCESS_SERVERS) {
const count = payload.readU8();
if (count > 1) throw new Error('PDA 1.2 допускает максимум один access server');
accessServers = [];
for (let j = 0; j < count; j += 1) accessServers.push(payload.readStrU8());
}
}
if (!rootKey || !clientKey || !forks || forks.length < 1) {
throw new Error('В PDA 1.2 отсутствуют обязательные блоки shine_users');
}
const active = forks[forks.length - 1];
const blockchain = {
blockchainType: 1,
blockchainName: `${String(login).toLowerCase()}-${String(forks.length).padStart(3, '0')}`,
blockchainPublicKey: active.blockchainKey,
paidLimitBytes: active.paidLimitBytes,
usedBytes: 0n,
lastBlockNumber: 0,
lastBlockHash: new Uint8Array(32),
lastBlockSignature: new Uint8Array(64),
arweaveTxId: '',
};
const signature = bytes.slice(recordLen - 64, recordLen);
return {
formatMajor,
formatMinor,
recordLen,
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
login,
blocksCount,
recoveryKey: null,
rootKey,
clientKey,
forks,
blockchain,
serverProfile,
accessServers,
sessionsMode: DEFAULT_SESSIONS_MODE,
sessions: [],
trustedCount: 0,
signature,
};
}
function parseShineUserPda(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
if (bytes.length < 9) throw new Error('Повреждённый формат PDA');
const magic = new TextDecoder().decode(bytes.slice(0, 5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA: magic не совпадает');
const formatMajor = bytes[5];
const formatMinor = bytes[6];
if (formatMajor === 1 && formatMinor === 2) return parseShineUserPdaV12(bytes);
if (formatMajor === 1 && formatMinor === 0) return parseShineUserPdaLegacy(bytes);
throw new Error(`Неподдерживаемый формат PDA ${formatMajor}.${formatMinor}`);
}
async function digestSha256(bytes) {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
const hash = await crypto.subtle.digest('SHA-256', input);
@@ -2272,7 +2404,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
const gateway = baseUrl(gatewayBaseUrl);
const ownerAddress = await arweaveOwnerAddressFromBlockchainKey(blockchainKey);
const tags = [{ name: 'App', values: [SHINE_ARWEAVE_APP_TAG] }];
if (channelName) tags.push({ name: 'c_test5590', values: [channelName] });
if (channelName) tags.push({ name: 'c', values: [channelName] });
const query = `query($owners:[String!],$tags:[TagFilter!],$after:String){transactions(first:100,after:$after,owners:$owners,tags:$tags,sort:HEIGHT_ASC){pageInfo{hasNextPage}edges{cursor node{id owner{address key} block{height timestamp} bundledIn{id} tags{name value}}}}}`;
let after = null;
const items = [];
@@ -2438,7 +2570,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
signatureHex: bytesToHex(parsed.signature),
},
keys: {
recoveryKeyBase58: bytesToBase58(parsed.recoveryKey),
recoveryKeyBase58: parsed.recoveryKey ? bytesToBase58(parsed.recoveryKey) : '',
rootKeyBase58: bytesToBase58(parsed.rootKey),
clientKeyBase58: bytesToBase58(parsed.clientKey),
},
+135 -3
View File
@@ -1553,7 +1553,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
};
}
function parseShineUserPda(dataBytes) {
function parseShineUserPdaLegacy(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
const reader = makeReader(bytes);
const magic = new TextDecoder().decode(reader.readBytes(5));
@@ -1717,6 +1717,138 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
};
}
function parseShineUserPdaV12(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
const reader = makeReader(bytes);
const magic = new TextDecoder().decode(reader.readBytes(5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA: magic не совпадает');
const formatMajor = reader.readU8();
const formatMinor = reader.readU8();
const recordLen = reader.readU16();
if (formatMajor !== 1 || formatMinor !== 2 || recordLen < 73 || recordLen > bytes.length) {
throw new Error('Некорректный формат PDA 1.2');
}
const createdAtMs = reader.readU64();
const updatedAtMs = reader.readU64();
const recordNumber = reader.readU32();
const prevRecordHash = reader.readBytes(32);
const login = reader.readStrU8();
const blocksCount = reader.readU8();
let rootKey = null;
let clientKey = null;
let forks = null;
let serverProfile = null;
let accessServers = [];
for (let index = 0; index < blocksCount; index += 1) {
const blockType = reader.readU8();
const blockVersion = reader.readU8();
if (blockType === BLOCK_TYPE_ROOT_KEY) {
if (blockVersion !== 0) throw new Error('Неизвестная версия RootKeyBlock');
rootKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_CLIENT_KEY) {
if (blockVersion !== 0) throw new Error('Неизвестная версия ClientKeyBlock');
clientKey = reader.readBytes(32);
continue;
}
const payloadLen = reader.readU16();
const payload = makeReader(reader.readBytes(payloadLen));
if (blockVersion !== 0) continue;
if (blockType === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
const count = payload.readU16();
if (count < 1) throw new Error('BlockchainRegistry 1.2 пуст');
forks = [];
for (let j = 0; j < count; j += 1) {
forks.push({
blockchainKey: payload.readBytes(32),
paidLimitBytes: BigInt(payload.readU32()),
});
}
} else if (blockType === BLOCK_TYPE_SERVER_PROFILE) {
const count = payload.readU8();
const addresses = [];
for (let j = 0; j < count; j += 1) {
addresses.push({
addressFormatType: payload.readU8(),
addressFormatVersion: payload.readU8(),
address: payload.readStrU8(),
});
}
const first = addresses[0] || null;
serverProfile = first ? {
blockVersion,
addressFormatType: first.addressFormatType,
addressFormatVersion: first.addressFormatVersion,
serverAddress: first.address,
addresses,
syncServers: [],
} : null;
} else if (blockType === BLOCK_TYPE_ACCESS_SERVERS) {
const count = payload.readU8();
if (count > 1) throw new Error('PDA 1.2 допускает максимум один access server');
accessServers = [];
for (let j = 0; j < count; j += 1) accessServers.push(payload.readStrU8());
}
}
if (!rootKey || !clientKey || !forks || forks.length < 1) {
throw new Error('В PDA 1.2 отсутствуют обязательные блоки shine_users');
}
const active = forks[forks.length - 1];
const blockchain = {
blockchainType: 1,
blockchainName: `${String(login).toLowerCase()}-${String(forks.length).padStart(3, '0')}`,
blockchainPublicKey: active.blockchainKey,
paidLimitBytes: active.paidLimitBytes,
usedBytes: 0n,
lastBlockNumber: 0,
lastBlockHash: new Uint8Array(32),
lastBlockSignature: new Uint8Array(64),
arweaveTxId: '',
};
const signature = bytes.slice(recordLen - 64, recordLen);
return {
formatMajor,
formatMinor,
recordLen,
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
login,
blocksCount,
recoveryKey: null,
rootKey,
clientKey,
forks,
blockchain,
serverProfile,
accessServers,
sessionsMode: DEFAULT_SESSIONS_MODE,
sessions: [],
trustedCount: 0,
signature,
};
}
function parseShineUserPda(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
if (bytes.length < 9) throw new Error('Повреждённый формат PDA');
const magic = new TextDecoder().decode(bytes.slice(0, 5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA: magic не совпадает');
const formatMajor = bytes[5];
const formatMinor = bytes[6];
if (formatMajor === 1 && formatMinor === 2) return parseShineUserPdaV12(bytes);
if (formatMajor === 1 && formatMinor === 0) return parseShineUserPdaLegacy(bytes);
throw new Error(`Неподдерживаемый формат PDA ${formatMajor}.${formatMinor}`);
}
async function digestSha256(bytes) {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
const hash = await crypto.subtle.digest('SHA-256', input);
@@ -2285,7 +2417,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
const gateway = baseUrl(gatewayBaseUrl);
const ownerAddress = await arweaveOwnerAddressFromBlockchainKey(blockchainKey);
const tags = [{ name: 'App', values: [SHINE_ARWEAVE_APP_TAG] }];
if (channelName) tags.push({ name: 'c_test5590', values: [channelName] });
if (channelName) tags.push({ name: 'c', values: [channelName] });
const query = `query($owners:[String!],$tags:[TagFilter!],$after:String){transactions(first:100,after:$after,owners:$owners,tags:$tags,sort:HEIGHT_DESC){pageInfo{hasNextPage}edges{cursor node{id owner{address key} block{height timestamp} bundledIn{id} tags{name value}}}}}`;
let after = null;
let page = 0;
@@ -2472,7 +2604,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
signatureHex: bytesToHex(parsed.signature),
},
keys: {
recoveryKeyBase58: bytesToBase58(parsed.recoveryKey),
recoveryKeyBase58: parsed.recoveryKey ? bytesToBase58(parsed.recoveryKey) : '',
rootKeyBase58: bytesToBase58(parsed.rootKey),
clientKeyBase58: bytesToBase58(parsed.clientKey),
},
+134 -2
View File
@@ -1538,7 +1538,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
};
}
function parseShineUserPda(dataBytes) {
function parseShineUserPdaLegacy(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
const reader = makeReader(bytes);
const magic = new TextDecoder().decode(reader.readBytes(5));
@@ -1697,6 +1697,138 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
};
}
function parseShineUserPdaV12(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
const reader = makeReader(bytes);
const magic = new TextDecoder().decode(reader.readBytes(5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA: magic не совпадает');
const formatMajor = reader.readU8();
const formatMinor = reader.readU8();
const recordLen = reader.readU16();
if (formatMajor !== 1 || formatMinor !== 2 || recordLen < 73 || recordLen > bytes.length) {
throw new Error('Некорректный формат PDA 1.2');
}
const createdAtMs = reader.readU64();
const updatedAtMs = reader.readU64();
const recordNumber = reader.readU32();
const prevRecordHash = reader.readBytes(32);
const login = reader.readStrU8();
const blocksCount = reader.readU8();
let rootKey = null;
let clientKey = null;
let forks = null;
let serverProfile = null;
let accessServers = [];
for (let index = 0; index < blocksCount; index += 1) {
const blockType = reader.readU8();
const blockVersion = reader.readU8();
if (blockType === BLOCK_TYPE_ROOT_KEY) {
if (blockVersion !== 0) throw new Error('Неизвестная версия RootKeyBlock');
rootKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_CLIENT_KEY) {
if (blockVersion !== 0) throw new Error('Неизвестная версия ClientKeyBlock');
clientKey = reader.readBytes(32);
continue;
}
const payloadLen = reader.readU16();
const payload = makeReader(reader.readBytes(payloadLen));
if (blockVersion !== 0) continue;
if (blockType === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
const count = payload.readU16();
if (count < 1) throw new Error('BlockchainRegistry 1.2 пуст');
forks = [];
for (let j = 0; j < count; j += 1) {
forks.push({
blockchainKey: payload.readBytes(32),
paidLimitBytes: BigInt(payload.readU32()),
});
}
} else if (blockType === BLOCK_TYPE_SERVER_PROFILE) {
const count = payload.readU8();
const addresses = [];
for (let j = 0; j < count; j += 1) {
addresses.push({
addressFormatType: payload.readU8(),
addressFormatVersion: payload.readU8(),
address: payload.readStrU8(),
});
}
const first = addresses[0] || null;
serverProfile = first ? {
blockVersion,
addressFormatType: first.addressFormatType,
addressFormatVersion: first.addressFormatVersion,
serverAddress: first.address,
addresses,
syncServers: [],
} : null;
} else if (blockType === BLOCK_TYPE_ACCESS_SERVERS) {
const count = payload.readU8();
if (count > 1) throw new Error('PDA 1.2 допускает максимум один access server');
accessServers = [];
for (let j = 0; j < count; j += 1) accessServers.push(payload.readStrU8());
}
}
if (!rootKey || !clientKey || !forks || forks.length < 1) {
throw new Error('В PDA 1.2 отсутствуют обязательные блоки shine_users');
}
const active = forks[forks.length - 1];
const blockchain = {
blockchainType: 1,
blockchainName: `${String(login).toLowerCase()}-${String(forks.length).padStart(3, '0')}`,
blockchainPublicKey: active.blockchainKey,
paidLimitBytes: active.paidLimitBytes,
usedBytes: 0n,
lastBlockNumber: 0,
lastBlockHash: new Uint8Array(32),
lastBlockSignature: new Uint8Array(64),
arweaveTxId: '',
};
const signature = bytes.slice(recordLen - 64, recordLen);
return {
formatMajor,
formatMinor,
recordLen,
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
login,
blocksCount,
recoveryKey: null,
rootKey,
clientKey,
forks,
blockchain,
serverProfile,
accessServers,
sessionsMode: DEFAULT_SESSIONS_MODE,
sessions: [],
trustedCount: 0,
signature,
};
}
function parseShineUserPda(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
if (bytes.length < 9) throw new Error('Повреждённый формат PDA');
const magic = new TextDecoder().decode(bytes.slice(0, 5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA: magic не совпадает');
const formatMajor = bytes[5];
const formatMinor = bytes[6];
if (formatMajor === 1 && formatMinor === 2) return parseShineUserPdaV12(bytes);
if (formatMajor === 1 && formatMinor === 0) return parseShineUserPdaLegacy(bytes);
throw new Error(`Неподдерживаемый формат PDA ${formatMajor}.${formatMinor}`);
}
async function digestSha256(bytes) {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
const hash = await crypto.subtle.digest('SHA-256', input);
@@ -2377,7 +2509,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
signatureHex: bytesToHex(parsed.signature),
},
keys: {
recoveryKeyBase58: bytesToBase58(parsed.recoveryKey),
recoveryKeyBase58: parsed.recoveryKey ? bytesToBase58(parsed.recoveryKey) : '',
rootKeyBase58: bytesToBase58(parsed.rootKey),
clientKeyBase58: bytesToBase58(parsed.clientKey),
},
+2 -1
View File
@@ -9,7 +9,8 @@
- `shine_login_guard.md`
- `shine_payments.md`
2. Документы по форматам в `doc/formats/`:
- `shine-user-pda-format-v.1.0.md`
- `shine-user-pda-format-v.1.2.md` — текущий формат;
- `shine-user-pda-format-v.1.0.md` — исторический legacy-формат; новые записи его не создают, миграции нет.
Эти документы должны быть достаточными для повторной реализации программ и форматов с нуля.
@@ -1,10 +1,10 @@
# Solana user_pda: итоговый целевой формат пользовательской записи
# Solana user_pda: формат 1.0 (LEGACY)
Документ описывает целевой формат пользовательской PDA-записи `user_pda` для Solana-программы `shine_users`.
Документ описывает legacy-формат пользовательской PDA-записи `user_pda` для Solana-программы `shine_users`.
Это не формат основного блокчейна SHiNE и не документация по `AddBlock`. Основной блокчейн SHiNE описан отдельно в `docs/Blockchain/`.
Статус документа: итоговый согласованный формат, к которому приведены `create_user_pda`, `update_user_pda` и тестовый сериализатор Solana-модуля.
Статус документа: legacy. Новые записи создаются только в формате 1.2; миграции 1.0 → 1.2 нет. Формат сохранён как историческое описание тестовых PDA, которые можно закрыть отдельной временной инструкцией.
## 1. Назначение user_pda
@@ -0,0 +1,184 @@
# Solana `user_pda`: формат 1.2
Актуальный бинарный формат пользовательской PDA программы `shine_users`.
Формат пользовательских SHiNE-блоков и Arweave этим изменением не меняется.
## Назначение
PDA 1.2 хранит идентичность и authority пользователя, append-only историю blockchain keys, оплаченный лимит и маршрутизацию.
Состояние вершины пользовательского blockchain в Solana больше не хранится.
Из PDA 1.2 удалены `RecoveryKeyBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`, `sync_servers[]`, а также старые `used_bytes`, `last_block_*`, `arweave_tx_id`, `blockchain_name/type`.
## Общие правила кодирования
- Little Endian;
- `Pubkey` — 32 байта;
- Ed25519 signature — 64 байта;
- hash — 32 байта;
- строка — `len:u8 + UTF-8 bytes`;
- `block_type:u8 + block_version:u8` есть у каждого typed block;
- фиксированные `RootKeyBlock` и `ClientKeyBlock` не содержат длину;
- любой variable block: `block_type:u8 + block_version:u8 + payload_len:u16 + payload`;
- неизвестный variable block можно пропустить по `payload_len`.
Все известные блоки PDA 1.2 имеют `block_version = 0`.
## Header
```text
magic: [u8;5] = "SHiNE"
format_major: u8 = 1
format_minor: u8 = 2
record_len: u16
created_at_ms: u64
updated_at_ms: u64
record_number: u32
prev_record_hash: [u8;32]
login_len: u8
login: [u8;login_len]
blocks_count: u8
blocks: TypedBlock[blocks_count]
signature: [u8;64]
```
`record_len` включает подпись и не включает padding Solana account.
`record_number=0` на create и увеличивается программой на 1 после каждого успешного update.
`prev_record_hash=0` на create; при update это hash unsigned-части предыдущей PDA.
## Типы блоков
| type | блок | статус |
|---:|---|---|
| 1 | `RootKeyBlock` | обязательный |
| 2 | `ClientKeyBlock` | обязательный |
| 3 | `BlockchainRegistryBlock` | обязательный |
| 30 | `ServerProfileBlock` | опциональный |
| 40 | `AccessServersBlock` | опциональный |
## RootKeyBlock
```text
block_type: u8 = 1
block_version: u8 = 0
root_key: [u8;32]
```
`root_key` — холодный recovery authority. Blockchain authority не может изменить root. Root authority может изменить root и остальные поля.
## ClientKeyBlock
```text
block_type: u8 = 2
block_version: u8 = 0
client_key: [u8;32]
```
Client key не является authority PDA. Он может использоваться клиентским уровнем и как fee payer Solana-транзакции.
## BlockchainRegistryBlock
```text
block_type: u8 = 3
block_version: u8 = 0
payload_len: u16
fork_count: u16
forks[fork_count]:
blockchain_key: [u8;32]
created_at_ms: u64
paid_limit_bytes: u32
```
Одна fork-запись занимает 44 байта.
Правила:
- `fork_count >= 1`;
- последний fork — активный;
- старые fork-записи append-only: их нельзя удалить, переставить или изменить;
- один blockchain key нельзя добавить повторно;
- `paid_limit_bytes` старых fork неизменяем;
- лимит можно пополнять только у последнего fork;
- новый fork наследует текущий лимит плюс оплаченный top-up;
- обычный blockchain authority может создать новый fork не раньше чем через 72 часа после `created_at_ms` активного fork;
- root recovery может создать fork без 72-часового ожидания.
`created_at_ms` передаёт клиент и включает в подписываемую PDA. Программа принимает его только если он отличается от Solana Clock не более чем на ±5 минут.
## ServerProfileBlock
Блок отсутствует у обычного пользователя. Наличие блока означает, что PDA публикует SHiNE-server endpoint.
```text
block_type: u8 = 30
block_version: u8 = 0
payload_len: u16
address_count: u8
addresses[address_count]:
address_format_type: u8
address_format_version: u8
address_len: u8
address: [u8;address_len]
```
PDA 1.2 разрешает **ровно один адрес**, если `ServerProfileBlock` присутствует. Массив/count сохранён для будущего увеличения лимита без изменения бинарной структуры.
`address_format_type + address_format_version` позволяют позже стандартизовать URL, IPv4, IPv6, Tor/I2P и другие адресные форматы.
## AccessServersBlock
```text
block_type: u8 = 40
block_version: u8 = 0
payload_len: u16
server_count: u8
servers[server_count]:
login_len: u8
login: [u8;login_len]
```
PDA 1.2 разрешает `server_count = 0` или `1`. Count оставлен для будущего расширения.
## Authority и подпись
Новая PDA хранит `signature[64]` authority **нового состояния** по hash unsigned PDA:
- обычный update — активный blockchain key;
- новый fork — новый blockchain key;
- root-mode — root нового состояния;
- смена root — новый root.
На update программа дополнительно требует Ed25519-подпись authority предыдущего состояния по тому же hash нового unsigned state. Эта transition-подпись не хранится внутри PDA.
Blockchain-mode не может изменить root. Root-mode может изменить root и выполнить recovery. Одновременно менять root и добавлять blockchain fork одной транзакцией запрещено.
## Create
Создаются только PDA 1.2.
```text
record_number = 0
prev_record_hash = 0x00 * 32
fork_count = 1
fork[0].blockchain_key = initial blockchain key
fork[0].created_at_ms = created_at_ms
fork[0].paid_limit_bytes = start_bonus + paid top-up
```
Root доказывает владение recovery-key отдельной Ed25519 instruction, а `signature[64]` новой PDA делает initial blockchain key.
## Legacy 1.0
Нормальные create/update/read flows нового клиента и сервера поддерживают только PDA 1.2.
Временно существует instruction `close_legacy_pda` для тестовых PDA 1.0. Она:
- принимает только PDA с `format_major=1`, `format_minor=0`;
- не может закрыть 1.2;
- закрывает старый program-owned account;
- переводит его lamports вызывающему signer.
Legacy migration в 1.2 отсутствует. После удаления тестовых PDA временную close-инструкцию можно удалить из программы.
+92 -730
View File
@@ -1,775 +1,137 @@
# Программа `shine_users`
Документ описывает целевое поведение Solana-программы регистрации пользователей SHiNE.
Актуальный пользовательский формат — `user_pda 1.2`.
Полный бинарный формат: `doc/formats/shine-user-pda-format-v.1.2.md`.
Legacy 1.0 считается тестовым и не мигрируется.
Назначение документа:
## Что хранит программа
- быть источником истины при поддержке текущей реализации;
- позволить заново реализовать программу без Anchor;
- зафиксировать инварианты, форматы и правила проверки.
- `root_key` — cold recovery authority;
- `client_key`;
- append-only список blockchain fork keys с временем создания и оплаченной квотой;
- один optional server address;
- ноль или один access server;
- `record_number`, `prev_record_hash` и переносимую Ed25519-подпись состояния.
Если код программы расходится с этим документом, это считается ошибкой: нужно либо исправить код, либо обновить документ в том же изменении.
Пользовательские SHiNE-блоки/Arweave этим патчем не меняются.
## 1. Назначение программы
## Инструкции
`shine_users` хранит публичную пользовательскую запись SHiNE в Solana PDA и управляет её экономикой.
### `1 init_users_economy_config`
Без изменений.
Программа отвечает за:
### `2 update_users_economy_config`
Без изменений.
- создание `user_pda` по логину;
- обновление `user_pda` без смены логина и root key;
- хранение economy-конфига регистрации;
- хранение PDA продавцов promo-логинов;
- взимание комиссии за регистрацию и увеличение лимита;
- проверку Ed25519-подписей `root_key` и `blockchain_public_key`;
- проверку promo-подписей продавца для обхода premium/trademark login guard;
- проверку связности новой версии записи с предыдущей через `prev_record_hash`.
Программа не отвечает за:
- хранение приватных ключей;
- проверку существования Arweave tx;
- валидацию того, что логины из `sync_servers` или `access_servers` реально существуют как серверы;
- выполнение SHiNE-блокчейна пользователя;
- хранение серверных auth-сессий.
## 2. Program ID и внешние зависимости
Текущий program id:
- `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
Внешние зависимости по логике:
- `shine_payments`
- используется только как источник PDA inflow-вольта;
- `shine_login_guard`
- используется для классификации логина через CPI;
- системная программа Solana;
- sysvar `instructions`.
Текущие зашитые внешние адреса:
- `DAO_AUTHORITY`: `aiShm43fZjm3YkMs22sYL1bpXaL3bVxv7SSraPHzVgq`
- `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v`
- `shine_login_guard`: `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh`
## 3. PDA и seed-правила
### 3.1. Пользовательская PDA
Пользовательская запись строится так:
- seed prefix: `user_login=`
- второй seed: логин в нижнем регистре
- program id: `shine_users`
Формула:
### `3 create_user_pda`
```text
user_pda = PDA(["user_login=", lower(login)], shine_users_program_id)
tag:u8 = 3
login:string_u8
root_key:[32]
created_at_ms:u64
additional_limit:u64
client_key:[32]
blockchain_key:[32]
address_count:u8 // 0 или 1
[address]
access_server_count:u8 // 0 или 1
[access_server]
record_signature:[64]
[promo_seller_login:string_u8]
```
### 3.2. Economy config PDA
PDA экономических настроек:
- seed: `shine_users_economy_config`
Формула:
Server address:
```text
users_economy_config_pda = PDA(["shine_users_economy_config"], shine_users_program_id)
address_format_type:u8
address_format_version:u8
address:string_u8
```
### 3.3. Правило создания PDA (защита от «минирования» адреса)
Fee payer — любой переданный signer; он не обязан совпадать с client key.
`created_at_ms` должен быть в пределах ±5 минут от Solana Clock.
Root даёт proof-of-possession, а initial blockchain key подписывает саму новую PDA.
Адрес пользовательской PDA выводится из логина и публично предсказуем: зная желаемый логин,
любой может заранее вычислить адрес записи и перевести на него немного лампортов обычным
system-переводом. Если бы создание шло строго через `system_instruction::create_account`,
такой «подсев» приводил бы к ошибке «account already in use» и навсегда блокировал бы
регистрацию этого логина (targeted-DoS / сквоттинг логинов), причём без оплаты комиссии.
### `4 update_user_pda`
Поэтому `create_pda_account` создаёт аккаунт устойчиво к предзаполненному балансу:
- если на адресе нет лампортов — обычный `create_account` (быстрый путь);
- если лампорты уже есть — «создание поверх предзаполненного»: добор ренты переводом,
затем `allocate` + `assign` под подписью PDA.
Проверки повторной инициализации (`owner == System Program` и пустые данные) остаются и
не зависят от баланса аккаунта.
### 3.4. PDA продавца promo-логинов
PDA продавца строится так:
- seed prefix: `promo_seller=`
- второй seed: логин продавца в нижнем регистре
Формула:
Работает только с PDA 1.2.
```text
promo_seller_pda = PDA(["promo_seller=", lower(seller_login)], shine_users_program_id)
tag:u8 = 4
login:string_u8
new_root_key:[32]
updated_at_ms:u64
additional_limit:u64
new_client_key:[32]
auth_mode:u8 // 0 blockchain, 1 root
new_blockchain_present:u8
[new_blockchain_key:[32]]
address_count:u8 // 0 или 1
[address]
access_server_count:u8 // 0 или 1
[access_server]
record_signature:[64]
```
В программе может существовать сколько угодно таких PDA, по одному на каждого продавца.
Программа сама вычисляет `record_number=old+1` и `prev_record_hash`.
`updated_at_ms` должен быть в пределах ±5 минут от Solana Clock.
### 3.5. Строгий список аккаунтов (нет «лишних» аккаунтов)
Перед update идут две Ed25519 instructions по hash нового unsigned state:
Все инструкции `shine_users` читают строго фиксированный набор аккаунтов и после этого
явно требуют, чтобы в переданном списке больше ничего не было
(`require!(it.next().is_none(), InvalidInstruction)`). Если вызывающий добавит лишние
аккаунты в хвост, инструкция завершится ошибкой `InvalidInstruction (1)`.
1. старый authority разрешает переход;
2. authority нового состояния подписывает запись; эта подпись сохраняется в PDA.
Это не закрывает отдельной уязвимости (каждый используемый аккаунт и так строго
валидируется по signer/owner/адресу PDA), а является defense-in-depth и приводит поведение
к единому виду с `shine_payments`, где такая же проверка стоит во всех инструкциях.
Списки аккаунтов в разделах ниже надо считать исчерпывающими и точными по количеству.
Blockchain-mode:
## 4. Состояния программы
- старый authority = последний blockchain key;
- root менять нельзя;
- новый fork — только после 72 часов от времени активного fork.
### 4.1. `UsersEconomyConfigState`
Root-mode:
Хранится в `users_economy_config_pda`.
- старый authority = root;
- root можно менять;
- recovery fork может обходить 72-часовой cooldown.
Поля:
Root rotation и blockchain fork одной транзакцией запрещены.
- `version: u8`
- `registration_fee_lamports: u64`
- `lamports_per_limit_step: u64`
- `start_bonus_limit: u64`
### `5 upsert_promo_seller`
Без изменений.
Смысл:
### `6 close_legacy_pda`
- `registration_fee_lamports` — базовая плата за регистрацию;
- `lamports_per_limit_step` — стоимость одного шага лимита;
- `start_bonus_limit` — стартовый бесплатный лимит записи, который получает новый пользователь.
### 4.2. `user_pda`
Формат пользовательской записи описан отдельно:
- [shine-user-pda-format-v.1.0.md](/home/ai/work/SHiNE/SHiNE-server-sha256/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md)
Этот документ описывает именно логику программы, а не байтовую структуру блока.
### 4.3. `promo_seller_pda`
PDA продавца красивых логинов хранит:
- `version: u8`
- `remaining_sales: u64`
- `min_login_length: u8`
- `signer_pubkey: [u8; 32]`
Смысл:
- `remaining_sales` — сколько ещё красивых логинов продавец может выдать;
- `min_login_length` — минимальная длина логина, которую продавец имеет право раздавать;
- `signer_pubkey` — публичный Ed25519-ключ, которым off-chain подписываются promo-коды.
## 5. Константы и базовые правила
Базовые значения из текущей логики:
- seed `user_pda`: `user_login=`
- seed economy config: `shine_users_economy_config`
- seed promo seller PDA: `promo_seller=`
- стартовый размер `user_pda`: `768` байт
- размер `promo_seller_pda`: `64` байта
- `LIMIT_STEP = 10_000`
- `START_REGISTRATION_FEE_LAMPORTS = 10_000_000`
- `START_LAMPORTS_PER_LIMIT_STEP = 100_000`
- `START_BONUS_LIMIT = 100_000`
- префикс promo-сообщения: `shine_promo_v1:`
Правила:
- `additional_limit` всегда кратен `LIMIT_STEP`;
- `paid_limit_bytes` не может уменьшаться;
- `used_bytes` не может уменьшаться;
- `last_block_number` не может уменьшаться;
- `root_key` после создания не меняется;
- логин после создания не меняется;
- `created_at_ms` после создания не меняется.
## 6. Ключи и подписи
В записи участвуют четыре ключевых роли:
- `recovery_key`
- публичный recovery-ключ пользователя для будущих сценариев восстановления;
- `root_key`
- корневая подпись самой записи;
- `client_key`
- текущий плательщик и signer транзакции create/update;
- `blockchain_public_key`
- ключ подтверждения вершины пользовательского SHiNE-блокчейна.
### 6.1. Что программа видит on-chain
Программа работает только с:
- публичными ключами `32` байта;
- подписями `64` байта;
- сообщениями для Ed25519-проверки.
Программа не знает и не должна знать:
- PKCS#8 контейнеры;
- PEM;
- способ хранения приватного ключа на клиенте;
- откуда клиент извлёк `seed32` для `client_key`.
### 6.2. Практика клиентской генерации ключей
Off-chain клиентская логика может хранить приватные ключи в PKCS#8 и извлекать `seed32` для `client`-signer. Это допустимая клиентская реализация, но не часть on-chain формата.
Согласованная клиентская схема деривации для первой публичной версии:
Временная cleanup-инструкция для тестовых PDA 1.0.
```text
seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || suffix_utf8)
tag:u8 = 6
login:string_u8
```
Согласованные suffix:
Accounts:
1. caller signer + writable;
2. legacy user PDA writable.
Программа проверяет PDA seed/owner и `format=1.0`, переводит все lamports PDA вызывающему и обнуляет account data. PDA 1.2 этой инструкцией закрыть нельзя.
## BlockchainRegistry 1.2
```text
"recovery.key"
"root.key"
"blockchain.key"
"client.key"
fork_count:u16
forks[]:
blockchain_key:[32]
created_at_ms:u64
paid_limit_bytes:u32
```
On-chain инвариант только один:
Последний fork активный. Старые записи неизменяемы.
- публичные ключи и подписи должны соответствовать друг другу.
## Server/access limits в 1.2
## 7. Логин и login guard
Бинарный формат использует count/arrays для forward compatibility, но текущая программа намеренно ограничивает:
Перед созданием пользователя логин обязан пройти две проверки:
1. базовая syntactic validation внутри `shine_users`;
2. CPI-вызов `shine_login_guard::classify_login`.
### 7.1. Базовая проверка логина
Логин должен:
- быть не пустым;
- быть длиной не больше `20` символов;
- содержать только `A-Z`, `a-z`, `0-9`, `_`.
### 7.2. Классификация через `shine_login_guard`
`shine_users` вызывает `shine_login_guard` и читает `return_data`.
Классы:
- `0` — логин разрешён;
- `1` — premium login, регистрация запрещена автоматически;
- `2` — trademark login, требует отдельного review и не регистрируется автоматически.
Если `shine_login_guard` вернул что-то иное или return_data некорректны, это ошибка.
### 7.3. Promo-обход login guard
При создании пользователя off-chain клиент может использовать опциональный `promo_code`.
Формат строки:
```text
1<seller_login>-<signature_base58>
```
Где:
- первый символ `1` — версия формата promo-кода;
- `seller_login` — логин продавца, по которому ищется `promo_seller_pda`;
- `signature_base58` — Ed25519-подпись по сообщению:
```text
shine_promo_v1:<login_из_запроса_create_user_pda>
```
В сам `create_user_pda` при этом передаётся только `seller_login`, а подпись проверяется по отдельной Ed25519-инструкции в транзакции.
Если promo-код валиден, то:
- premium/trademark-проверка через `shine_login_guard` не выполняется;
- базовая синтаксическая проверка логина всё равно остаётся обязательной;
- `remaining_sales` уменьшается на `1`;
- длина логина должна быть не меньше `min_login_length`.
Если promo-код не передан, обычная CPI-проверка через `shine_login_guard` обязательна.
## 8. Инструкция `init_users_economy_config`
### Назначение
Создать `users_economy_config_pda` со стартовыми параметрами экономики.
### Аккаунты
- signer/payer
- `users_economy_config_pda`
- system program
### Правила
- PDA должна ещё не существовать;
- адрес PDA обязан совпадать с seed `shine_users_economy_config`;
- в PDA записывается стартовый `UsersEconomyConfigState`.
### Бинарный ABI
```text
- tag: u8 = 1
```
## 9. Инструкция `update_users_economy_config`
### Назначение
Изменить экономические параметры регистрации.
### Авторизация
Только `DAO_AUTHORITY`.
### Аккаунты
- signer
- `users_economy_config_pda`
### Правила
- signer должен совпадать с `DAO_AUTHORITY`;
- PDA должна существовать и принадлежать программе;
- `lamports_per_limit_step > 0`.
### Бинарный ABI
```text
- tag: u8 = 2
- registration_fee_lamports: u64 LE
- lamports_per_limit_step: u64 LE
- start_bonus_limit: u64 LE
```
## 10. Инструкция `create_user_pda`
### Назначение
Создать новую пользовательскую запись по логину.
### Кто платит
Плательщик транзакции и signer инструкции:
- `client_key`
Это принципиальное правило:
- `root_key` только подписывает запись;
- `client_key` оплачивает rent/fees/registration flow.
### Аккаунты
- signer = `client_key`
- `user_pda`
- system program
- inflow vault PDA из `shine_payments`
- sysvar `instructions`
- `users_economy_config_pda`
- `shine_login_guard_program`
- опционально: `promo_seller_pda`, если регистрация идёт по promo-коду
### Входные данные
- логин
- `recovery_key`
- `root_key`
- `created_at_ms`
- `additional_limit`
- mutable fields записи
- `root` signature по unsigned части
- опционально `promo_seller_login`
### Бинарный ABI
```text
- tag: u8 = 3
- login: string_u8
- recovery_key: [u8; 32]
- root_key: [u8; 32]
- created_at_ms: u64 LE
- additional_limit: u64 LE
- fields: UserMutableFieldsV1
- root_signature: [u8; 64]
- optional trailing promo_seller_login: string_u8
```
### Обязательные проверки
1. Логин валиден по синтаксису.
2. Если promo-код не передан, логин разрешён `shine_login_guard`.
3. Если promo-регистрация включена, программа получает `promo_seller_login`, а связанная Ed25519-инструкция должна:
- ссылаться на существующий `promo_seller_pda`;
- проходить Ed25519-проверку через `signer_pubkey` продавца;
- иметь `remaining_sales > 0`;
- удовлетворять правилу `login.len() >= min_login_length`.
4. `additional_limit % LIMIT_STEP == 0`.
5. inflow vault совпадает с PDA программы `shine_payments`.
6. `user_pda` вычислена правильно и ещё не существует.
7. Поля блокчейна валидны.
8. Поля server/session/trusted валидны по формату.
9. `last_block_signature` соответствует `LastBlockState`.
10. `root signature` соответствует unsigned части записи.
11. Размер сериализованной записи не превышает допустимый стартовый размер PDA или иные ограничения реализации.
### Экономика
При создании:
- пользователь получает `start_bonus_limit`;
- дополнительно может купить `additional_limit`;
- итоговый оплаченный лимит:
```text
paid_limit_bytes = start_bonus_limit + additional_limit
```
Комиссия:
```text
total_fee = registration_fee_lamports + limit_fee(additional_limit)
```
Где:
```text
limit_fee(additional_limit) = (additional_limit / LIMIT_STEP) * lamports_per_limit_step
```
### Результат
- создаётся PDA;
- в неё записывается полная запись `user_pda`;
- если используется promo-код, в `promo_seller_pda` уменьшается `remaining_sales`;
- средства переводятся в inflow vault `shine_payments`.
## 11. Инструкция `upsert_promo_seller`
### Назначение
Создать нового продавца promo-логинов или полностью перезаписать настройки уже существующего продавца.
### Авторизация
Только `DAO_AUTHORITY`.
### Аккаунты
- signer
- `promo_seller_pda`
- system program
### Входные данные
- `seller_login`
- `remaining_sales`
- `min_login_length`
- `signer_pubkey`
### Бинарный ABI
```text
- tag: u8 = 5
- seller_login: string_u8
- remaining_sales: u64 LE
- min_login_length: u8
- signer_pubkey: [u8; 32]
```
### Правила
- signer должен совпадать с `DAO_AUTHORITY`;
- `seller_login` проходит ту же базовую проверку логина;
- `min_login_length` лежит в диапазоне `1..20`;
- адрес PDA обязан совпадать с `promo_seller=` + `lower(seller_login)`;
- если PDA ещё нет, она создаётся;
- если PDA уже есть, её состояние полностью перезаписывается.
## 12. Инструкция `update_user_pda`
### Назначение
Создать новую версию той же пользовательской записи.
### Авторизация
Те же роли:
- signer/fee payer = `client_key`
- подпись записи = `root_key`
- подпись вершины блокчейна = `blockchain_public_key`
### Аккаунты
- signer = `client_key`
- `user_pda`
- system program
- inflow vault PDA из `shine_payments`
- sysvar `instructions`
- `users_economy_config_pda`
### Обязательные проверки
1. PDA существует и принадлежит `shine_users`.
2. Новый логин совпадает со старым.
3. `created_at_ms` совпадает со старым.
4. `recovery_key` совпадает со старым.
5. `root_key` совпадает со старым.
6. `client_key` совпадает со старым.
7. `version = old.record_number + 1`.
8. `prev_hash = hash(unsigned_old_record)`.
9. `additional_limit % LIMIT_STEP == 0`.
10. `blockchain_name` и `blockchain_public_key` не меняются.
11. `paid_limit_bytes` не уменьшается.
12. `used_bytes` не уменьшается.
13. `last_block_number` не уменьшается.
14. Если состояние блокчейна изменилось, `last_block_signature` заново проверяется через Ed25519.
15. Новая unsigned часть записи подписана `root_key`.
16. При необходимости PDA может быть расширена через realloc.
### Экономика
При update оплачивается только докупаемый лимит:
```text
topup_fee = limit_fee(additional_limit)
```
Если `additional_limit = 0`, доплата не требуется.
### Бинарный ABI
```text
- tag: u8 = 4
- login: string_u8
- recovery_key: [u8; 32]
- root_key: [u8; 32]
- created_at_ms: u64 LE
- updated_at_ms: u64 LE
- version: u32 LE
- prev_hash: [u8; 32]
- additional_limit: u64 LE
- fields: UserMutableFieldsV1
- root_signature: [u8; 64]
```
## 13. Ed25519-проверки и порядок инструкций
В обычной транзакции `create/update` должны стоять две встроенные Ed25519-инструкции прямо перед вызовом `shine_users`:
1. подпись `root_key` по unsigned записи;
2. подпись `blockchain_public_key` по `LastBlockState`.
Текущая логика `shine_users` читает их через sysvar `instructions` относительно текущего индекса:
- `-2` — `root_key`
- `-1` — `blockchain_public_key`
Это правило порядка является частью контракта между off-chain клиентом и программой.
Если `create_user_pda` вызывается с promo-кодом, то перед ними добавляется ещё одна Ed25519-инструкция:
1. подпись продавца promo-логина по строке `shine_promo_v1:<login>`;
2. подпись `root_key` по unsigned записи;
3. подпись `blockchain_public_key` по `LastBlockState`.
Тогда программа читает:
- `-3` — promo-подпись;
- `-2` — `root_key`;
- `-1` — `blockchain_public_key`.
## 14. LastBlockState
Сообщение для подписи `blockchain_public_key`:
```text
- constant: "SHiNE_LAST_BLOCK"
- login
- blockchain_name
- last_block_number
- last_block_hash[32]
- used_bytes
```
Алгоритм:
```text
message_hash = SHA-256(LastBlockState bytes)
signature = Ed25519(blockchain_private_key, message_hash)
```
## 15. Валидируемые mutable-поля записи
Программа допускает обновление:
- `used_bytes`
- `last_block_number`
- `last_block_hash`
- `last_block_signature`
- `arweave_tx_id`
- `is_server`
- `server profile`
- `access_servers`
- `sessions_mode`
- `sessions`
- `trusted_count`
- `additional_limit`
Программа не допускает update:
- `login`
- `created_at_ms`
- `recovery_key`
- `root_key`
- `client_key`
- `blockchain_name`
- `blockchain_public_key`
- `blockchain_type`
## 16. Правила серверных и сессионных полей
### UserMutableFieldsV1
```text
- client_key: [u8; 32]
- blockchain_public_key: [u8; 32]
- blockchain_name: string_u8
- used_bytes: u64 LE
- last_block_number: u32 LE
- last_block_hash: [u8; 32]
- last_block_signature: [u8; 64]
- arweave_tx_id: string_u8
- is_server: u8
- if is_server = 1:
- address_format_type: u8
- address_format_version: u8
- server_address: string_u8
- sync_servers_count: u8
- sync_servers[sync_servers_count]: string_u8[]
- access_servers_count: u8
- access_servers[access_servers_count]: string_u8[]
- sessions_mode: u8
- sessions_count: u8
- sessions[sessions_count]:
- session_type: u8
- session_version: u8
- session_name: string_u8
- session_pub_key: [u8; 32]
- trusted_count: u8
```
### Server profile
Если `is_server = false`:
- `server_address` должен быть пустой;
- `sync_servers` должен быть пустой.
Если `is_server = true`:
- `server_address` обязателен;
- `sync_servers.len() <= 32`.
### Sessions block
Формат сессий описан в PDA-формате, но логика такая:
- максимум `64` записей;
- `sessions_mode` допускает только `1` и `10`;
- `session_type` допускает `1`, `50` и `100`;
- `session_version` сейчас только `1`;
- `session_name` должен содержать только `[A-Za-z0-9_]`;
- `session_name` и `session_pub_key` уникальны внутри списка.
На текущем этапе обычная регистрация пользователя должна продолжать работать с:
- `sessions_mode = 1`
- `sessions = []`
## 17. Realloc поведения PDA
Запись может расти. Если новая сериализованная запись длиннее текущего размера PDA:
- PDA разрешено увеличить через realloc;
- нельзя делать чрезмерный рост одним шагом выше внутреннего лимита реализации;
- перед realloc нужно обеспечить rent для нового размера.
## 18. Ошибки и классы отказа
Программа должна различать как минимум такие классы ошибок:
- неверный логин;
- premium/trademark login;
- неверный PDA адрес;
- PDA уже существует / PDA пустая / PDA не принадлежит программе;
- неверный формат записи;
- неверная подпись root;
- неверная подпись last block state;
- попытка изменить immutable поля;
- неверная версия;
- неверный `prev_hash`;
- попытка уменьшить лимит/used_bytes/block number;
- overflow;
- неверный inflow vault;
- неверный DAO authority для economy config и promo seller update;
- неверный promo-код;
- несуществующий или повреждённый `promo_seller_pda`;
- исчерпанная promo-квота;
- promo-подпись не совпадает;
- логин короче `min_login_length`.
## 19. Что должно сохраниться при переписи без Anchor
В текущей чисто-rust реализации уже сохранены:
- те же PDA seed-правила;
- тот же формат `user_pda`;
- ту же экономику регистрации и topup;
- тот же порядок Ed25519-инструкций;
- те же immutable/mutable правила;
- ту же валидацию логина и CPI в `shine_login_guard`;
- ту же promo-механику с PDA продавцов и подписью `shine_promo_v1:<login>`;
- ту же зависимость от inflow vault программы `shine_payments`.
Сознательно не сохранялись:
- структура Anchor `Context`;
- Anchor discriminator'ы и Anchor-ABI инструкций;
- старые seed'ы, которые конфликтовали с уже существующим Anchor-состоянием в devnet;
- внутренние helper-функции старой реализации.
## ArchiveHeadBlock и серверный SHINE-ARCHIVE
Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`):
```text
archive_tx_id [32]
archive_hash [32]
```
Он хранит текущую голову архива конкретного SHiNE-аккаунта: raw Arweave TX ID и SHA-256 соответствующего большого `SHINE-ARCHIVE`. Подробный бинарный формат и серверный workflow находятся в `docs/Archive/01_PROTOCOL_v1.0.md`.
Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим:
- legacy payload без archive extension сохраняет старый block `100`;
- новый payload может заменить/очистить archive head;
- итоговая полная User PDA запись, включая block `100`, покрывается обычной root-подписью.
Это позволяет обычным старым клиентским обновлениям профиля не стирать archive head серверного publisher-а.
- `ServerProfileBlock`: один адрес;
- `AccessServersBlock`: 0 или 1 сервер доступа.
Позже лимиты можно увеличить без изменения структуры блоков.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import {
parseShineUserPda,
serializeUnsignedRecordFromState,
} from '../../../shine-UI/js/services/shine-user-pda-service.js';
const bytes = (len, seed) => Uint8Array.from({ length: len }, (_, i) => (seed + i) & 0xff);
const state = {
login: 'codec_test',
createdAtMs: 1_000n,
updatedAtMs: 2_000n,
recordNumber: 4,
prevRecordHash: bytes(32, 1),
rootKey: bytes(32, 40),
clientKey: bytes(32, 80),
forks: [
{ blockchainKey: bytes(32, 120), createdAtMs: 1_000n, paidLimitBytes: 100_000n },
{ blockchainKey: bytes(32, 160), createdAtMs: 2_000n, paidLimitBytes: 200_000n },
],
serverAddresses: [
{ addressFormatType: 1, addressFormatVersion: 0, address: 'https://s.example' },
],
accessServers: ['access1'],
};
const unsigned = serializeUnsignedRecordFromState(state);
const full = new Uint8Array(unsigned.length + 64);
full.set(unsigned);
full.set(bytes(64, 200), unsigned.length);
const parsed = parseShineUserPda(full);
assert.equal(parsed.formatMajor, 1);
assert.equal(parsed.formatMinor, 2);
assert.equal(parsed.isLegacy, false);
assert.equal(parsed.recordNumber, 4);
assert.equal(parsed.forks.length, 2);
assert.equal(parsed.forks[1].createdAtMs, 2_000n);
assert.equal(parsed.forks[1].paidLimitBytes, 200_000n);
assert.equal(parsed.serverAddresses.length, 1);
assert.deepEqual(parsed.accessServers, ['access1']);
assert.equal(parsed.blockchain.blockchainName, 'codec_test-002');
console.log(`PDA 1.2 codec smoke test OK (${full.length} bytes)`);
+2 -1
View File
@@ -1,3 +1,4 @@
// Legacy 1.0 e2e kept only as a historical fixture. PDA 1.2 uses the native compact instruction wire format; see pda-v1-2-codec.mjs and the 1.2 format spec.
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import {
@@ -189,7 +190,7 @@ function extractSigFromEdIx(ixData: Buffer): Buffer {
return ixData.subarray(signatureOffset, signatureOffset + 64);
}
describe("shine_users e2e", () => {
describe.skip("legacy shine_users PDA 1.0 e2e (reference only)", () => {
anchor.setProvider(anchor.AnchorProvider.env());
const provider = anchor.getProvider() as anchor.AnchorProvider;
const program = anchor.workspace.shine as Program<Shine>;