diff --git a/Dev_Docs/Pending_Features/2026-05-24_1940_solana-user-pda-v2.md b/Dev_Docs/Pending_Features/2026-05-24_1940_solana-user-pda-v2.md new file mode 100644 index 0000000..8211810 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-24_1940_solana-user-pda-v2.md @@ -0,0 +1,23 @@ +# Solana user_pda v2 + +## Краткое описание + +Функции `create_user_pda` и `update_user_pda` в Solana-модуле переведены на блочный формат пользовательской PDA-записи `format_major = 2`. + +## Что проверять + +- Создание `user_pda` через `create_user_pda`. +- Обновление `user_pda` через `update_user_pda`. +- Проверку root-подписи записи. +- Проверку подписи `LastBlockState` ключом `blockchain_public_key`. +- Корректную запись блоков `RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `TrustedState`. +- Рост `paid_limit_bytes`, `used_bytes` и `last_block_number` без возможности уменьшения. +- Совместимость тестового клиента с актуальной IDL после `anchor build`. + +## Ожидаемый результат + +Пользовательская PDA создается и обновляется в формате `format_major = 2`, содержит один основной блокчейн `blockchain_type = 1` с именем `-001`, а неверные подписи или попытки уменьшить счетчики отклоняются программой. + +## Статус + +pending diff --git a/Dev_Docs/Solana/user_pda/README.md b/Dev_Docs/Solana/user_pda/README.md index 532666b..299b098 100644 --- a/Dev_Docs/Solana/user_pda/README.md +++ b/Dev_Docs/Solana/user_pda/README.md @@ -4,7 +4,7 @@ Это не формат основного блокчейна SHiNE и не документация по `AddBlock`. Основной блокчейн SHiNE описан отдельно в `Dev_Docs/Blockchain/`. -Статус документа: итоговый согласованный целевой формат. Текущий код Solana-модуля пока использует старый линейный формат записи; этот документ должен стать основой для изменения кода, тестов и интеграции с сервером. +Статус документа: итоговый согласованный формат, к которому приведены `create_user_pda`, `update_user_pda` и тестовый сериализатор Solana-модуля. ## 1. Назначение user_pda diff --git a/VERSION.properties b/VERSION.properties index 569dcef..0de0f55 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.89 -server.version=1.2.83 +client.version=1.2.90 +server.version=1.2.84 diff --git a/shine-solana/shine/doc/SHiNE-user-format-v.1.0.md b/shine-solana/shine/doc/SHiNE-user-format-v.1.0.md index cfb260f..299b098 100644 --- a/shine-solana/shine/doc/SHiNE-user-format-v.1.0.md +++ b/shine-solana/shine/doc/SHiNE-user-format-v.1.0.md @@ -1,225 +1,365 @@ -# SHINY USER FORMAT v1.0 (DRAFT) +# Solana user_pda: итоговый целевой формат пользовательской записи -Документ описывает целевой бинарный формат пользовательской записи в `user_pda` для программы `shine_users`. +Документ описывает целевой формат пользовательской PDA-записи `user_pda` для Solana-программы `shine_users`. -## 1) Статус версии и цель +Это не формат основного блокчейна SHiNE и не документация по `AddBlock`. Основной блокчейн SHiNE описан отдельно в `Dev_Docs/Blockchain/`. -- Текущий on-chain формат: `v1.0`. -- Этот документ: `v1.0 (draft)` для текущего этапа. -- Цель текущей версии: зафиксировать рабочий формат и сразу оставить в нем поля для будущего расширения. +Статус документа: итоговый согласованный формат, к которому приведены `create_user_pda`, `update_user_pda` и тестовый сериализатор Solana-модуля. -Новые статусные поля: -- `root_key_status` -- `blockchain_key_status` -- `device_key_status` +## 1. Назначение user_pda -Текущее значение каждого статуса: `0` (ключ создан и не менялся). +`user_pda` хранит публичное состояние пользователя в Solana: -## 2) Общие правила кодирования +- логин пользователя; +- неизменяемые параметры создания записи; +- корневой публичный ключ пользователя; +- ключ устройства; +- данные одного или нескольких пользовательских блокчейнов SHiNE; +- серверные данные пользователя, если пользователь выступает сервером; +- серверы доступа пользователя; +- счетчики/лимиты; +- подпись записи. -- Числа: Little Endian (`LE`). -- Строки: `UTF-8` с префиксом длины `u8`. -- Публичные ключи: 32 байта (`Pubkey`). -- Подпись: 64 байта (Ed25519). -- Размер PDA фиксированный: `USER_PDA_SPACE` (сейчас 1024 байта). -- `record_len` хранит длину полезной записи от `magic` до `signature` включительно (без `padding`). +На первом этапе поддерживается один пользовательский блокчейн SHiNE, но формат блока блокчейна сразу допускает повторение таких блоков в будущем. -## 3) Единый список полей в порядке хранения +## 2. Адрес PDA -1. `magic` - Размер: 5 байт. - Значение: `"SHiNE"`. - Назначение: маркер формата записи. +Адрес пользовательской PDA вычисляется по логину: -2. `format_major` - Размер: 1 байт (`u8`). - Текущее значение: `1`. - Назначение: major-версия формата. +- seed prefix: `login=`; +- второй seed: нормализованный логин в нижнем регистре; +- program id: программа `shine_users`. -3. `format_minor` - Размер: 1 байт (`u8`). - Текущее значение: `0`. - Назначение: minor-версия формата. +Один логин соответствует одной `user_pda`. -4. `record_len` - Размер: 2 байта (`u16`, LE). - Назначение: длина полезных данных записи (без `padding`). +## 3. Общие правила кодирования -5. `created_at_ms` - Размер: 8 байт (`u64`, LE). - Назначение: время создания записи (Unix time, ms). +- Числа кодируются в 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`. -6. `updated_at_ms` - Размер: 8 байт (`u64`, LE). - Назначение: время последнего обновления записи (Unix time, ms). +## 4. Верхний формат записи -7. `record_number` (`version`) - Размер: 4 байта (`u32`, LE). - Назначение: порядковый номер записи пользователя. - Правило обновления: новая запись должна иметь `last_record_number + 1`; проверяется программой. +Первые 9 полей фиксированы и идут строго в указанном порядке. Это общий заголовок записи. -8. `prev_record_hash` (`prev_hash`) - Размер: 32 байта. - Назначение: хэш unsigned-части предыдущей записи для связи истории. +| N | Поле | Тип | Размер | Правило | +|---|------|-----|--------|---------| +| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. | +| 2 | `format_major` | `u8` | 1 | Для нового формата: `2`. | +| 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. `login_len` - Размер: 1 байт (`u8`). - Назначение: длина поля `login` в байтах. +После первых 9 полей идет набор типизированных блоков: -10. `login` - Размер: `login_len` байт (UTF-8). - Назначение: логин пользователя. - Текущие ограничения: от 1 до 25 символов, только `a-z`, `0-9`, `_`. +```text +UserPdaRecordV2 +- fixed_header: поля 1..9 +- blocks_count: u8 +- blocks: TypedBlock[blocks_count] +- signature: [u8; 64] +- padding: bytes до размера PDA, если нужен +``` -11. `root_key_status` - Размер: 1 байт (`u8`). - Текущее значение: `0`. - Назначение: статус `root_key`. - Комментарий: будущие статусы ротации зарезервированы, смена root-ключа пока не реализована. +`blocks_count` входит в unsigned-часть записи и подписывается. -12. `root_key` - Размер: 32 байта (`Pubkey`). - Назначение: корневой ключ пользователя для подписи записи. +## 5. Типы блоков -13. `blockchain_key_status` - Размер: 1 байт (`u8`). - Текущее значение: `0`. - Назначение: статус `blockchain_key`. - Комментарий: будущие статусы ротации зарезервированы. +Зарезервированные значения `block_type`: -14. `blockchain_key` - Размер: 32 байта (`Pubkey`). - Назначение: рабочий блокчейн-ключ пользователя. +| block_type | Блок | Назначение | +|------------|------|------------| +| `1` | `RootKeyBlock` | Корневой ключ пользователя. | +| `2` | `DeviceKeyBlock` | Ключ устройства пользователя. | +| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. | +| `30` | `ServerProfileBlock` | Серверные данные пользователя. | +| `40` | `AccessServersBlock` | Серверы доступа/relay. | +| `50` | `TrustedStateBlock` | Счетчик trusted-связей. | +| `255` | `ReservedBlock` | Зарезервировано, пока не используется. | -15. `device_key_status` - Размер: 1 байт (`u8`). - Текущее значение: `0`. - Назначение: статус `device_key`. - Комментарий: будущие статусы ротации зарезервированы. +Правила: -16. `device_key` - Размер: 32 байта (`Pubkey`). - Назначение: ключ устройства пользователя. +- неизвестный `block_type` в `format_major = 2` считается ошибкой; +- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`; +- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `TrustedStateBlock`; +- каждый обязательный блок должен встречаться ровно один раз; +- порядок блоков в записи фиксируется для простоты проверки: + `RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `TrustedState`. -17. `chain_number` - Размер: 2 байта (`u16`, LE). - Назначение: номер блокчейн-профиля пользователя. - Текущее использование: базовый сценарий с одним профилем (обычно `1`). +## 6. RootKeyBlock -18. `balance` - Размер: 8 байт (`u64`, LE). - Назначение: лимит/баланс пользователя. +Смена `root_key` пока не проектируется и не реализуется. Блок фиксирует только стадию `0`. -19. `is_server` - Размер: 1 байт (`u8`). - Значения: `0` или `1`. - Назначение: флаг серверного профиля. +```text +RootKeyBlock +- block_type: u8 = 1 +- block_version: u8 = 0 +- root_key: [u8; 32] +``` -20. `server_key` (только если `is_server = 1`) - Размер: 32 байта (`Pubkey`). - Назначение: публичный ключ сервера. +Правила: -21. `server_address_len` (только если `is_server = 1`) - Размер: 1 байт (`u8`). - Назначение: длина строки `server_address`. +- при создании задается корневой публичный ключ пользователя; +- при обновлении `root_key` должен совпадать с предыдущей записью; +- ротация root-key будет отдельным форматом/сценарием в будущем. -22. `server_address` (только если `is_server = 1`) - Размер: `server_address_len` байт (UTF-8). - Назначение: адрес сервера. +## 7. DeviceKeyBlock -23. `sync_servers_count` (только если `is_server = 1`) - Размер: 1 байт (`u8`). - Назначение: количество серверов, с которыми сервер синхронизирует данные. - Ограничение: максимум `32`. +Смена `device_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один ключ устройства. -24. Повтор `sync_servers_count` раз (только если `is_server = 1`): - `server_login_len` — 1 байт (`u8`), - `server_login` — `server_login_len` байт (UTF-8). - Назначение: логины серверов синхронизации. +```text +DeviceKeyBlock +- block_type: u8 = 2 +- block_version: u8 = 0 +- device_key: [u8; 32] +``` -25. `access_servers_count` - Размер: 1 байт (`u8`). - Назначение: количество серверов доступа (relay), через которые можно достучаться до пользователя. +Правила: -26. Повтор `access_servers_count` раз: - `server_login_len` — 1 байт (`u8`), - `server_login` — `server_login_len` байт (UTF-8). - Назначение: логины серверов доступа. +- при создании задается текущий публичный ключ устройства; +- при обновлении ключ устройства может быть обновлен только если это отдельно разрешено бизнес-логикой инструкции; +- история устройств и несколько устройств в этом формате не хранятся. -27. `trusted_count` - Размер: 1 байт (`u8`). - Назначение: текущее число trusted-контактов. - Текущее состояние: пока только счетчик, без отдельной trusted-логики. +## 8. BlockchainRegistryBlock -28. `reserved` - Размер: 5 байт. - Текущее значение: `0x00 0x00 0x00 0x00 0x00`. - Назначение: резерв под будущие расширения. +Блок хранит данные пользовательских блокчейнов SHiNE. Сейчас используется один блокчейн, но структура сразу сделана как список. -29. `signature` - Размер: 64 байта. - Назначение: Ed25519-подпись хэша unsigned-части записи. +```text +BlockchainRegistryBlock +- block_type: u8 = 3 +- block_version: u8 = 0 +- blockchain_count: u8 +- blockchain_records: BlockchainRecord[blockchain_count] +``` -30. `padding` - Размер: до полного `USER_PDA_SPACE`. - Текущее значение: `0x00`. - Назначение: добивка до фиксированного размера PDA. +Правила: -## 4) Что подписывается +- на первом этапе `blockchain_count = 1`; +- в будущем можно увеличить количество записей без изменения смысла `BlockchainRecord`; +- каждый `BlockchainRecord` описывает один пользовательский SHiNE-блокчейн. -Подписывается SHA-256 от unsigned-части записи: -- от `magic` до `reserved` включительно; -- без `signature`; -- без `padding`. +## 9. BlockchainRecord -## 5) Что сейчас работает в логике +```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 +``` -Сейчас в рабочем потоке используются 2 операции: -1. `create_user_pda` — регистрация пользователя. -2. `update_user_pda` — обновление записи пользователя. +`blockchain_type`: -Через `update_user_pda` сейчас можно: -- увеличить `balance` через `additional_limit`; -- обновить серверные поля (`is_server`, `server_key`, `server_address`, `sync_servers`, `access_servers`); -- увеличить `record_number` (`version`) на 1. +| Значение | Смысл | +|----------|-------| +| `1` | Основной пользовательский SHiNE-блокчейн. | -Оплата идет на адрес, заданный в `REGISTRATION_FEE_RECEIVER` (не в DAO по умолчанию). +Поля: -## 6) Ограничения и отложенные расширения +- `blockchain_name` - строковое имя пользовательского блокчейна, например `login-001`. На первом этапе для основного блокчейна пользователя используется имя вида `-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, где лежит выгруженный пользовательский канал/состояние. -Это функции и сценарии, которые предусмотрены структурой данных формата `v1.0`, но пока не реализованы программно. +Arweave `tx_id` - обычное поле внутри записи конкретного блокчейна. Solana-программа не проверяет, что такой Arweave transaction действительно существует и содержит корректные данные; это ответственность клиента/сервера/пользователя. -1. Смена ключей пока недоступна - `root_key`, `blockchain_key`, `device_key` считаются без ротации; статусные поля пока фактически только `0`. +## 10. Правила обновления BlockchainRecord -2. Multi-chain профили пока не реализованы - Пока используется один базовый профиль (`chain_number`), расширение до нескольких профилей/форков — отдельный этап. +При обновлении записи: -3. Trusted-логика пока не реализована - Пока хранится только `trusted_count`; список trusted, очередь, таймеры и голосование будут добавляться отдельно. +- `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`; +- `arweave_tx_id` можно добавить или заменить на новый, если пользователь выгрузил более актуальное состояние в Arweave; +- уменьшать лимит, число блоков или занятый размер нельзя. -4. Работа с несколькими серверами на уровне приложения ограничена - В записи можно хранить `sync_servers` и `access_servers`, но фактическая клиентская логика выбора/обхода серверов может быть ограничена. +Сообщение `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 +``` -## 7) Константы и фиксированные значения (точки будущего расширения) +Алгоритм: -Ниже перечислены места, где сейчас используются константы/фиксированные значения, а в будущем возможна доработка: +```text +message = SHA-256(LastBlockState bytes) +last_block_signature = Ed25519(blockchain_public_key, message) +``` -1. Версия формата: `format_major = 1`, `format_minor = 0`. - Расширение: переход на следующую минорную/мажорную версию при изменении бинарной схемы. +Причина проверки подписи `LastBlockState`: `root_key` управляет Solana-записью пользователя, а `blockchain_public_key` подтверждает состояние конкретного пользовательского блокчейна. Подписывается не голый хэш, а связка логина, имени блокчейна, номера последнего блока, хэша последнего блока и занятого размера. -2. Размер PDA: `USER_PDA_SPACE = 1024`. - Расширение: увеличение размера или переход на иное хранение при росте структуры. +## 11. ServerProfileBlock -3. Статусы ключей: все три `*_key_status` пока равны `0`. - Расширение: добавить коды состояний для ротации/восстановления ключей. +Блок присутствует, если пользователь выступает сервером. -4. `chain_number`: текущий рабочий сценарий с одним профилем (обычно `1`). - Расширение: поддержка нескольких блокчейн-форков. +```text +ServerProfileBlock +- block_type: u8 = 30 +- block_version: u8 = 0 +- is_server: u8 +- server_key: [u8; 32], только если 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 +``` -5. `trusted_count`: пока только счетчик, обычно `0`. - Расширение: отдельные структуры trusted-списка, очередей и таймеров. +Правила: -6. `reserved` (5 байт): сейчас всегда нули. - Расширение: использовать как флаги/дополнительные поля без слома общей схемы. +- `is_server = 0` означает, что серверных данных нет; +- `is_server = 1` означает, что пользователь публикует серверный профиль; +- `sync_servers_count` максимум `32`; +- `server_address` - строковый адрес сервера в формате, который будет отдельно закреплен на уровне приложения; +- `sync_servers` - логины пользователей системы, через которых этот сервер пытается синхронизироваться. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы. + +## 12. 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. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы; +- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE. + +## 13. TrustedStateBlock + +Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик. + +```text +TrustedStateBlock +- block_type: u8 = 50 +- block_version: u8 = 0 +- trusted_count: u8 = 0 +``` + +Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат. + +## 14. Подпись 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`. + +Смену формата подписи сейчас не трогаем. + +## 15. Регистрация пользователя + +При регистрации: + +- PDA еще не должна существовать; +- логин проходит проверку формата и login guard; +- `record_number = 0`; +- `prev_record_hash = 0x00...00`; +- `created_at_ms = updated_at_ms`; +- обязательные блоки присутствуют; +- создается минимум один `BlockchainRecord`; +- стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит; +- `used_bytes <= paid_limit_bytes`; +- пользователь платит регистрационную комиссию; +- если покупается дополнительный лимит, пользователь платит комиссию за этот лимит; +- вся unsigned-часть записи подписана `root_key`. + +## 16. Обновление пользователя + +При обновлении: + +- PDA должна существовать; +- `login`, `created_at_ms`, `root_key` не меняются; +- `record_number = previous_record_number + 1`; +- `prev_record_hash` равен хэшу unsigned-части предыдущей записи; +- `updated_at_ms` обновляется; +- unsigned-часть новой записи подписана `root_key`; +- лимиты блокчейнов могут только увеличиваться; +- занятый размер и номер последнего блока не могут уменьшаться; +- при увеличении оплаченного лимита пользователь доплачивает комиссию; +- Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует. + +## 17. Отличия от старого линейного формата + +Старый формат после `login` хранил поля линейно: + +- `root_key_status`; +- `root_key`; +- `blockchain_key_status`; +- `blockchain_key`; +- `device_key_status`; +- `device_key`; +- `chain_number`; +- `balance`; +- серверные поля; +- access-серверы; +- `trusted_count`; +- `reserved`; +- `signature`. + +Новый целевой формат сохраняет первые 9 фиксированных полей как заголовок, но дальше переходит на типизированные блоки: + +- ключи становятся отдельными блоками; +- данные блокчейна становятся расширенным блоком со своим публичным ключом, лимитом, занятым размером, вершиной цепочки и Arweave `tx_id`; +- серверные данные и access-серверы отделяются от данных блокчейна; +- расширение формата делается добавлением новых версий блоков или новых `block_type`, а не вставкой полей в середину линейной записи. + +## 18. Что пока не входит в формат + +Пока не проектируем: + +- ротацию `root_key`; +- сложную ротацию `device_key`; +- ротацию `blockchain_public_key`; +- проверку содержимого Arweave transaction; +- хранение полной истории пользовательского блокчейна внутри Solana; +- подключение Solana-модуля к сборке/деплою основного сервера SHiNE. diff --git a/shine-solana/shine/programs/shine_users/src/users.rs b/shine-solana/shine/programs/shine_users/src/users.rs index eeca73d..7055f79 100644 --- a/shine-solana/shine/programs/shine_users/src/users.rs +++ b/shine-solana/shine/programs/shine_users/src/users.rs @@ -12,19 +12,31 @@ use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode}; use std::str::FromStr; const MAGIC: &[u8; 5] = b"SHiNE"; -const FORMAT_MAJOR: u8 = 1; +const FORMAT_MAJOR: u8 = 2; const FORMAT_MINOR: u8 = 0; -const KEY_STATUS_CREATED: u8 = 0; const MAX_SYNC_SERVERS: usize = 32; const MAX_AUTO_REALLOC_INCREASE: usize = 10_000; -const RESERVED_BYTES: [u8; 5] = [0, 0, 0, 0, 0]; const ZERO_HASH: [u8; 32] = [0; 32]; +const BLOCK_TYPE_ROOT_KEY: u8 = 1; +const BLOCK_TYPE_DEVICE_KEY: u8 = 2; +const BLOCK_TYPE_BLOCKCHAIN_REGISTRY: u8 = 3; +const BLOCK_TYPE_SERVER_PROFILE: u8 = 30; +const BLOCK_TYPE_ACCESS_SERVERS: u8 = 40; +const BLOCK_TYPE_TRUSTED_STATE: u8 = 50; +const BLOCK_VERSION_0: u8 = 0; +const BLOCKCHAIN_TYPE_MAIN_USER: u8 = 1; +const LAST_BLOCK_STATE_PREFIX: &[u8] = b"SHiNE_LAST_BLOCK"; #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] pub struct UserMutableFields { - pub blockchain_key: Pubkey, pub device_key: Pubkey, - pub chain_number: u16, + pub blockchain_public_key: Pubkey, + pub blockchain_name: String, + pub used_bytes: u64, + pub last_block_number: u32, + pub last_block_hash: Vec, + pub last_block_signature: Vec, + pub arweave_tx_id: String, pub is_server: bool, pub server_key: Pubkey, pub server_address: String, @@ -59,17 +71,12 @@ pub struct UpdateUserPdaArgs { pub struct UserRecord { pub created_at_ms: u64, pub updated_at_ms: u64, - pub version: u32, - pub prev_hash: [u8; 32], + pub record_number: u32, + pub prev_record_hash: [u8; 32], pub login: String, - pub root_key_status: u8, pub root_key: Pubkey, - pub blockchain_key_status: u8, - pub blockchain_key: Pubkey, - pub device_key_status: u8, pub device_key: Pubkey, - pub chain_number: u16, - pub balance: u64, + pub blockchain: BlockchainRecord, pub is_server: bool, pub server_key: Pubkey, pub server_address: String, @@ -79,6 +86,18 @@ pub struct UserRecord { pub signature: [u8; 64], } +pub struct BlockchainRecord { + pub blockchain_type: u8, + pub blockchain_name: String, + pub blockchain_public_key: Pubkey, + pub paid_limit_bytes: u64, + pub used_bytes: u64, + pub last_block_number: u32, + pub last_block_hash: [u8; 32], + pub last_block_signature: [u8; 64], + pub arweave_tx_id: String, +} + #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] pub struct UsersEconomyConfigState { pub version: u8, @@ -192,7 +211,11 @@ pub fn update_users_economy_config( ) -> Result<()> { let dao_authority = Pubkey::from_str(settings::DAO_AUTHORITY).map_err(|_| error!(ErrCode::InvalidSigner))?; - require_keys_eq!(dao_authority, ctx.accounts.signer.key(), ErrCode::InvalidSigner); + require_keys_eq!( + dao_authority, + ctx.accounts.signer.key(), + ErrCode::InvalidSigner + ); let (expected_pda, _) = find_users_economy_config_pda(ctx.program_id); require_keys_eq!( @@ -258,17 +281,22 @@ pub fn create_user_pda(ctx: Context, args: CreateUserPdaArgs) -> let mut record = UserRecord { created_at_ms: args.created_at_ms, updated_at_ms: args.created_at_ms, - version: 0, - prev_hash: ZERO_HASH, + record_number: 0, + prev_record_hash: ZERO_HASH, login: args.login.clone(), - root_key_status: KEY_STATUS_CREATED, root_key: args.root_key, - blockchain_key_status: KEY_STATUS_CREATED, - blockchain_key: args.fields.blockchain_key, - device_key_status: KEY_STATUS_CREATED, device_key: args.fields.device_key, - chain_number: args.fields.chain_number, - balance: start_balance, + blockchain: BlockchainRecord { + blockchain_type: BLOCKCHAIN_TYPE_MAIN_USER, + blockchain_name: args.fields.blockchain_name.clone(), + blockchain_public_key: args.fields.blockchain_public_key, + paid_limit_bytes: start_balance, + used_bytes: args.fields.used_bytes, + last_block_number: args.fields.last_block_number, + last_block_hash: vec_to_hash32(&args.fields.last_block_hash)?, + last_block_signature: vec_to_signature(&args.fields.last_block_signature)?, + arweave_tx_id: args.fields.arweave_tx_id.clone(), + }, is_server: args.fields.is_server, server_key: args.fields.server_key, server_address: args.fields.server_address.clone(), @@ -277,6 +305,8 @@ pub fn create_user_pda(ctx: Context, args: CreateUserPdaArgs) -> trusted_count: args.fields.trusted_count, signature: [0; 64], }; + validate_blockchain_limits(&record.blockchain, 0, 0, true)?; + verify_last_block_state_signature(&ctx.accounts.instructions, &record)?; let unsigned = serialize_unsigned_record(&record)?; record.signature = verify_record_signature( @@ -310,7 +340,10 @@ pub fn create_user_pda(ctx: Context, args: CreateUserPdaArgs) -> let total_fee = economy .registration_fee_lamports - .checked_add(limit_fee_lamports(args.additional_limit, economy.lamports_per_limit_step)?) + .checked_add(limit_fee_lamports( + args.additional_limit, + economy.lamports_per_limit_step, + )?) .ok_or(error!(ErrCode::MathOverflow))?; transfer_lamports( &ctx.accounts.signer, @@ -391,13 +424,7 @@ pub fn update_user_pda(ctx: Context, args: UpdateUserPdaArgs) -> ErrCode::ImmutableFieldChanged ); require!( - old_record.root_key_status == KEY_STATUS_CREATED - && old_record.blockchain_key_status == KEY_STATUS_CREATED - && old_record.device_key_status == KEY_STATUS_CREATED, - ErrCode::InvalidRecordData - ); - require!( - args.version == old_record.version.saturating_add(1), + args.version == old_record.record_number.saturating_add(1), ErrCode::InvalidVersion ); @@ -409,25 +436,34 @@ pub fn update_user_pda(ctx: Context, args: UpdateUserPdaArgs) -> ); let new_balance = old_record - .balance + .blockchain + .paid_limit_bytes .checked_add(args.additional_limit) .ok_or(error!(ErrCode::MathOverflow))?; - require!(new_balance >= old_record.balance, ErrCode::BalanceDecrease); + require!( + new_balance >= old_record.blockchain.paid_limit_bytes, + ErrCode::BalanceDecrease + ); let mut new_record = UserRecord { created_at_ms: old_record.created_at_ms, updated_at_ms: args.updated_at_ms, - version: args.version, - prev_hash: provided_prev_hash, + record_number: args.version, + prev_record_hash: provided_prev_hash, login: old_record.login.clone(), - root_key_status: old_record.root_key_status, root_key: old_record.root_key, - blockchain_key_status: old_record.blockchain_key_status, - blockchain_key: args.fields.blockchain_key, - device_key_status: old_record.device_key_status, device_key: args.fields.device_key, - chain_number: args.fields.chain_number, - balance: new_balance, + blockchain: BlockchainRecord { + blockchain_type: old_record.blockchain.blockchain_type, + blockchain_name: args.fields.blockchain_name.clone(), + blockchain_public_key: args.fields.blockchain_public_key, + paid_limit_bytes: new_balance, + used_bytes: args.fields.used_bytes, + last_block_number: args.fields.last_block_number, + last_block_hash: vec_to_hash32(&args.fields.last_block_hash)?, + last_block_signature: vec_to_signature(&args.fields.last_block_signature)?, + arweave_tx_id: args.fields.arweave_tx_id.clone(), + }, is_server: args.fields.is_server, server_key: args.fields.server_key, server_address: args.fields.server_address.clone(), @@ -436,6 +472,20 @@ pub fn update_user_pda(ctx: Context, args: UpdateUserPdaArgs) -> trusted_count: args.fields.trusted_count, signature: [0; 64], }; + require!( + new_record.blockchain.blockchain_type == old_record.blockchain.blockchain_type + && new_record.blockchain.blockchain_name == old_record.blockchain.blockchain_name + && new_record.blockchain.blockchain_public_key + == old_record.blockchain.blockchain_public_key, + ErrCode::ImmutableFieldChanged + ); + validate_blockchain_limits( + &new_record.blockchain, + old_record.blockchain.used_bytes, + old_record.blockchain.last_block_number, + false, + )?; + verify_last_block_state_signature(&ctx.accounts.instructions, &new_record)?; let unsigned = serialize_unsigned_record(&new_record)?; new_record.signature = verify_record_signature( @@ -471,13 +521,6 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result> { let login_bytes = record.login.as_bytes(); require!(login_bytes.len() <= u8::MAX as usize, ErrCode::InvalidLogin); - let server_address_bytes = record.server_address.as_bytes(); - require!( - server_address_bytes.len() <= u8::MAX as usize, - ErrCode::InvalidRecordData - ); - require!(record.access_servers.len() <= u8::MAX as usize, ErrCode::InvalidRecordData); - let mut out = Vec::new(); out.extend_from_slice(MAGIC); out.push(FORMAT_MAJOR); @@ -486,50 +529,22 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result> { out.extend_from_slice(&record.created_at_ms.to_le_bytes()); out.extend_from_slice(&record.updated_at_ms.to_le_bytes()); - out.extend_from_slice(&record.version.to_le_bytes()); - out.extend_from_slice(&record.prev_hash); + out.extend_from_slice(&record.record_number.to_le_bytes()); + out.extend_from_slice(&record.prev_record_hash); out.push(login_bytes.len() as u8); out.extend_from_slice(login_bytes); - out.push(record.root_key_status); - out.extend_from_slice(record.root_key.as_ref()); - out.push(record.blockchain_key_status); - out.extend_from_slice(record.blockchain_key.as_ref()); - out.push(record.device_key_status); - out.extend_from_slice(record.device_key.as_ref()); - - out.extend_from_slice(&record.chain_number.to_le_bytes()); - out.extend_from_slice(&record.balance.to_le_bytes()); - - out.push(if record.is_server { 1 } else { 0 }); + let blocks_count = if record.is_server { 6 } else { 5 }; + out.push(blocks_count); + write_root_key_block(&mut out, record); + write_device_key_block(&mut out, record); + write_blockchain_registry_block(&mut out, &record.blockchain)?; if record.is_server { - out.extend_from_slice(record.server_key.as_ref()); - out.push(server_address_bytes.len() as u8); - out.extend_from_slice(server_address_bytes); - require!( - record.sync_servers.len() <= MAX_SYNC_SERVERS, - ErrCode::InvalidRecordData - ); - out.push(record.sync_servers.len() as u8); - for login in &record.sync_servers { - let bytes = login.as_bytes(); - require!(bytes.len() <= u8::MAX as usize, ErrCode::InvalidRecordData); - out.push(bytes.len() as u8); - out.extend_from_slice(bytes); - } + write_server_profile_block(&mut out, record)?; } - - out.push(record.access_servers.len() as u8); - for login in &record.access_servers { - let bytes = login.as_bytes(); - require!(bytes.len() <= u8::MAX as usize, ErrCode::InvalidRecordData); - out.push(bytes.len() as u8); - out.extend_from_slice(bytes); - } - - out.push(record.trusted_count); - out.extend_from_slice(&RESERVED_BYTES); + write_access_servers_block(&mut out, record)?; + write_trusted_state_block(&mut out, record); let record_len = out .len() @@ -549,6 +564,121 @@ fn serialize_full_record(record: &UserRecord) -> Result> { Ok(out) } +fn write_root_key_block(out: &mut Vec, record: &UserRecord) { + out.push(BLOCK_TYPE_ROOT_KEY); + out.push(BLOCK_VERSION_0); + out.extend_from_slice(record.root_key.as_ref()); +} + +fn write_device_key_block(out: &mut Vec, record: &UserRecord) { + out.push(BLOCK_TYPE_DEVICE_KEY); + out.push(BLOCK_VERSION_0); + out.extend_from_slice(record.device_key.as_ref()); +} + +fn write_blockchain_registry_block(out: &mut Vec, blockchain: &BlockchainRecord) -> Result<()> { + out.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY); + out.push(BLOCK_VERSION_0); + out.push(1); + write_blockchain_record(out, blockchain)?; + Ok(()) +} + +fn write_blockchain_record(out: &mut Vec, blockchain: &BlockchainRecord) -> Result<()> { + out.push(blockchain.blockchain_type); + write_len_prefixed_string(out, &blockchain.blockchain_name)?; + out.extend_from_slice(blockchain.blockchain_public_key.as_ref()); + out.extend_from_slice(&blockchain.paid_limit_bytes.to_le_bytes()); + out.extend_from_slice(&blockchain.used_bytes.to_le_bytes()); + out.extend_from_slice(&blockchain.last_block_number.to_le_bytes()); + out.extend_from_slice(&blockchain.last_block_hash); + out.extend_from_slice(&blockchain.last_block_signature); + if blockchain.arweave_tx_id.is_empty() { + out.push(0); + } else { + out.push(1); + write_len_prefixed_string(out, &blockchain.arweave_tx_id)?; + } + Ok(()) +} + +fn write_server_profile_block(out: &mut Vec, record: &UserRecord) -> Result<()> { + out.push(BLOCK_TYPE_SERVER_PROFILE); + out.push(BLOCK_VERSION_0); + out.push(1); + out.extend_from_slice(record.server_key.as_ref()); + write_len_prefixed_string(out, &record.server_address)?; + require!( + record.sync_servers.len() <= MAX_SYNC_SERVERS, + ErrCode::InvalidRecordData + ); + out.push(record.sync_servers.len() as u8); + for login in &record.sync_servers { + write_len_prefixed_string(out, login)?; + } + Ok(()) +} + +fn write_access_servers_block(out: &mut Vec, record: &UserRecord) -> Result<()> { + out.push(BLOCK_TYPE_ACCESS_SERVERS); + out.push(BLOCK_VERSION_0); + require!( + record.access_servers.len() <= u8::MAX as usize, + ErrCode::InvalidRecordData + ); + out.push(record.access_servers.len() as u8); + for login in &record.access_servers { + write_len_prefixed_string(out, login)?; + } + Ok(()) +} + +fn write_trusted_state_block(out: &mut Vec, record: &UserRecord) { + out.push(BLOCK_TYPE_TRUSTED_STATE); + out.push(BLOCK_VERSION_0); + out.push(record.trusted_count); +} + +fn write_len_prefixed_string(out: &mut Vec, value: &str) -> Result<()> { + let bytes = value.as_bytes(); + require!(bytes.len() <= u8::MAX as usize, ErrCode::InvalidRecordData); + out.push(bytes.len() as u8); + out.extend_from_slice(bytes); + Ok(()) +} + +fn read_blockchain_record(data: &[u8], cursor: &mut usize) -> Result { + let blockchain_type = read_u8(data, cursor)?; + require!( + blockchain_type == BLOCKCHAIN_TYPE_MAIN_USER, + ErrCode::InvalidRecordData + ); + let blockchain_name = read_len_prefixed_string(data, cursor)?; + let blockchain_public_key = Pubkey::new_from_array(read_fixed_32(data, cursor)?); + let paid_limit_bytes = read_u64(data, cursor)?; + let used_bytes = read_u64(data, cursor)?; + let last_block_number = read_u32(data, cursor)?; + let last_block_hash = read_fixed_32(data, cursor)?; + let last_block_signature = read_fixed_64(data, cursor)?; + let arweave_present = read_u8(data, cursor)?; + let arweave_tx_id = match arweave_present { + 0 => String::new(), + 1 => read_len_prefixed_string(data, cursor)?, + _ => return Err(error!(ErrCode::InvalidRecordData)), + }; + Ok(BlockchainRecord { + blockchain_type, + blockchain_name, + blockchain_public_key, + paid_limit_bytes, + used_bytes, + last_block_number, + last_block_hash, + last_block_signature, + arweave_tx_id, + }) +} + fn deserialize_record_from_pda(raw: &[u8]) -> Result { require!(raw.len() >= 9, ErrCode::InvalidRecordData); require!(&raw[0..5] == MAGIC, ErrCode::InvalidRecordMagic); @@ -566,72 +696,81 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { let created_at_ms = read_u64(useful, &mut cursor)?; let updated_at_ms = read_u64(useful, &mut cursor)?; - let version = read_u32(useful, &mut cursor)?; - let prev_hash = read_fixed_32(useful, &mut cursor)?; + let record_number = read_u32(useful, &mut cursor)?; + let prev_record_hash = read_fixed_32(useful, &mut cursor)?; let login = read_len_prefixed_string(useful, &mut cursor)?; - let root_key_status = read_u8(useful, &mut cursor)?; - let root_key = Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?); - let blockchain_key_status = read_u8(useful, &mut cursor)?; - let blockchain_key = Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?); - let device_key_status = read_u8(useful, &mut cursor)?; - let device_key = Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?); + let blocks_count = read_u8(useful, &mut cursor)? as usize; + let mut root_key: Option = None; + let mut device_key: Option = None; + let mut blockchain: Option = None; + let mut is_server = false; + let mut server_key = Pubkey::default(); + let mut server_address = String::new(); + let mut sync_servers = Vec::new(); + let mut access_servers = Vec::new(); + let mut trusted_count = 0u8; - let chain_number = read_u16(useful, &mut cursor)?; - let balance = read_u64(useful, &mut cursor)?; - - let is_server = read_u8(useful, &mut cursor)? == 1; - let (server_key, server_address) = if is_server { - ( - Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?), - read_len_prefixed_string(useful, &mut cursor)?, - ) - } else { - (Pubkey::default(), String::new()) - }; - - let sync_servers = if is_server { - let sync_count = read_u8(useful, &mut cursor)? as usize; - require!(sync_count <= MAX_SYNC_SERVERS, ErrCode::InvalidRecordData); - let mut out = Vec::with_capacity(sync_count); - for _ in 0..sync_count { - out.push(read_len_prefixed_string(useful, &mut cursor)?); + for _ in 0..blocks_count { + let block_type = read_u8(useful, &mut cursor)?; + let block_version = read_u8(useful, &mut cursor)?; + require!( + block_version == BLOCK_VERSION_0, + ErrCode::InvalidRecordFormat + ); + match block_type { + BLOCK_TYPE_ROOT_KEY => { + require!(root_key.is_none(), ErrCode::InvalidRecordData); + root_key = Some(Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?)); + } + BLOCK_TYPE_DEVICE_KEY => { + require!(device_key.is_none(), ErrCode::InvalidRecordData); + device_key = Some(Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?)); + } + BLOCK_TYPE_BLOCKCHAIN_REGISTRY => { + require!(blockchain.is_none(), ErrCode::InvalidRecordData); + let count = read_u8(useful, &mut cursor)?; + require!(count == 1, ErrCode::InvalidRecordData); + blockchain = Some(read_blockchain_record(useful, &mut cursor)?); + } + BLOCK_TYPE_SERVER_PROFILE => { + require!(!is_server, ErrCode::InvalidRecordData); + is_server = read_u8(useful, &mut cursor)? == 1; + require!(is_server, ErrCode::InvalidRecordData); + server_key = Pubkey::new_from_array(read_fixed_32(useful, &mut cursor)?); + server_address = read_len_prefixed_string(useful, &mut cursor)?; + let sync_count = read_u8(useful, &mut cursor)? as usize; + require!(sync_count <= MAX_SYNC_SERVERS, ErrCode::InvalidRecordData); + for _ in 0..sync_count { + sync_servers.push(read_len_prefixed_string(useful, &mut cursor)?); + } + } + BLOCK_TYPE_ACCESS_SERVERS => { + require!(access_servers.is_empty(), ErrCode::InvalidRecordData); + let access_count = read_u8(useful, &mut cursor)? as usize; + for _ in 0..access_count { + access_servers.push(read_len_prefixed_string(useful, &mut cursor)?); + } + } + BLOCK_TYPE_TRUSTED_STATE => { + trusted_count = read_u8(useful, &mut cursor)?; + } + _ => return Err(error!(ErrCode::InvalidRecordFormat)), } - out - } else { - Vec::new() - }; - - let access_count = read_u8(useful, &mut cursor)? as usize; - let mut access_servers = Vec::with_capacity(access_count); - for _ in 0..access_count { - access_servers.push(read_len_prefixed_string(useful, &mut cursor)?); } - let trusted_count = read_u8(useful, &mut cursor)?; - require!( - useful.get(cursor..cursor + 5) == Some(&RESERVED_BYTES), - ErrCode::InvalidRecordData - ); - cursor += 5; - let signature = read_fixed_64(useful, &mut cursor)?; require!(cursor == useful.len(), ErrCode::InvalidRecordLength); Ok(UserRecord { created_at_ms, updated_at_ms, - version, - prev_hash, + record_number, + prev_record_hash, login, - root_key_status, - root_key, - blockchain_key_status, - blockchain_key, - device_key_status, - device_key, - chain_number, - balance, + root_key: root_key.ok_or(error!(ErrCode::InvalidRecordData))?, + device_key: device_key.ok_or(error!(ErrCode::InvalidRecordData))?, + blockchain: blockchain.ok_or(error!(ErrCode::InvalidRecordData))?, is_server, server_key, server_address, @@ -656,29 +795,71 @@ fn verify_record_signature( signature: &[u8], unsigned: &[u8], ) -> Result<[u8; 64]> { + let provided_sig = vec_to_signature(signature)?; + let msg_hash = hashv(&[unsigned]); + verify_ed25519_signature_instruction( + instructions_sysvar, + root_key, + &provided_sig, + msg_hash.as_ref(), + )?; + Ok(provided_sig) +} + +fn verify_last_block_state_signature( + instructions_sysvar: &AccountInfo, + record: &UserRecord, +) -> Result<()> { + let message = serialize_last_block_state(record)?; + let msg_hash = hashv(&[&message]); + verify_ed25519_signature_instruction( + instructions_sysvar, + &record.blockchain.blockchain_public_key, + &record.blockchain.last_block_signature, + msg_hash.as_ref(), + ) +} + +fn verify_ed25519_signature_instruction( + instructions_sysvar: &AccountInfo, + expected_pubkey: &Pubkey, + expected_signature: &[u8; 64], + expected_message: &[u8], +) -> Result<()> { require_keys_eq!( *instructions_sysvar.key, anchor_lang::solana_program::sysvar::instructions::id(), ErrCode::InvalidSignature ); - let provided_sig = vec_to_signature(signature)?; - let msg_hash = hashv(&[unsigned]); - let current_ix_index = load_current_index_checked(instructions_sysvar) .map_err(|_| error!(ErrCode::InvalidSignature))?; require!(current_ix_index > 0, ErrCode::InvalidSignature); - let ed_ix = load_instruction_at_checked((current_ix_index - 1) as usize, instructions_sysvar) - .map_err(|_| error!(ErrCode::InvalidSignature))?; + for ix_index in 0..current_ix_index { + let ed_ix = load_instruction_at_checked(ix_index as usize, instructions_sysvar) + .map_err(|_| error!(ErrCode::InvalidSignature))?; + if ed_ix.program_id != ed25519_program::id() { + continue; + } + let parsed = parse_ed25519_ix(&ed_ix)?; + if parsed.pubkey == *expected_pubkey + && parsed.signature == *expected_signature + && parsed.message == expected_message + { + return Ok(()); + } + } + Err(error!(ErrCode::InvalidSignature)) +} - let parsed = parse_ed25519_ix(&ed_ix)?; - require_keys_eq!(parsed.pubkey, *root_key, ErrCode::InvalidSignature); - require!( - parsed.message == msg_hash.as_ref(), - ErrCode::InvalidSignature - ); - require!(parsed.signature == provided_sig, ErrCode::InvalidSignature); - - Ok(parsed.signature) +fn serialize_last_block_state(record: &UserRecord) -> Result> { + let mut out = Vec::new(); + out.extend_from_slice(LAST_BLOCK_STATE_PREFIX); + write_len_prefixed_string(&mut out, &record.login)?; + write_len_prefixed_string(&mut out, &record.blockchain.blockchain_name)?; + out.extend_from_slice(&record.blockchain.last_block_number.to_le_bytes()); + out.extend_from_slice(&record.blockchain.last_block_hash); + out.extend_from_slice(&record.blockchain.used_bytes.to_le_bytes()); + Ok(out) } struct ParsedEd25519 { @@ -770,6 +951,22 @@ fn login_seed_normalized(login: &str) -> String { } fn validate_fields(fields: &UserMutableFields) -> Result<()> { + require!( + !fields.blockchain_name.is_empty(), + ErrCode::InvalidRecordData + ); + require!( + fields.blockchain_name.as_bytes().len() <= u8::MAX as usize, + ErrCode::InvalidRecordData + ); + require!( + fields.last_block_hash.len() == 32 && fields.last_block_signature.len() == 64, + ErrCode::InvalidRecordData + ); + require!( + fields.arweave_tx_id.as_bytes().len() <= u8::MAX as usize, + ErrCode::InvalidRecordData + ); if fields.is_server { require!( !fields.server_address.is_empty(), @@ -808,6 +1005,30 @@ fn validate_fields(fields: &UserMutableFields) -> Result<()> { Ok(()) } +fn validate_blockchain_limits( + blockchain: &BlockchainRecord, + old_used_bytes: u64, + old_last_block_number: u32, + is_create: bool, +) -> Result<()> { + require!( + blockchain.blockchain_type == BLOCKCHAIN_TYPE_MAIN_USER, + ErrCode::InvalidRecordData + ); + require!( + blockchain.used_bytes <= blockchain.paid_limit_bytes, + ErrCode::InvalidRecordData + ); + if !is_create { + require!( + blockchain.used_bytes >= old_used_bytes + && blockchain.last_block_number >= old_last_block_number, + ErrCode::InvalidRecordData + ); + } + Ok(()) +} + fn validate_inflow_vault(inflow_vault: &AccountInfo) -> Result<()> { let payments_program_id = Pubkey::from_str(settings::SHINE_PAYMENTS_PROGRAM_ID) .map_err(|_| error!(ErrCode::InvalidFeeReceiver))?; @@ -850,7 +1071,10 @@ fn ensure_pda_size_and_rent<'info>( let increase = required_len .checked_sub(current_len) .ok_or(error!(ErrCode::MathOverflow))?; - require!(increase <= MAX_AUTO_REALLOC_INCREASE, ErrCode::RecordTooLarge); + require!( + increase <= MAX_AUTO_REALLOC_INCREASE, + ErrCode::RecordTooLarge + ); let rent = Rent::get()?; let required_lamports = rent.minimum_balance(required_len); @@ -918,17 +1142,6 @@ fn read_u8(data: &[u8], cursor: &mut usize) -> Result { Ok(v) } -fn read_u16(data: &[u8], cursor: &mut usize) -> Result { - let end = cursor - .checked_add(2) - .ok_or(error!(ErrCode::InvalidRecordData))?; - let slice = data - .get(*cursor..end) - .ok_or(error!(ErrCode::InvalidRecordData))?; - *cursor = end; - Ok(u16::from_le_bytes([slice[0], slice[1]])) -} - fn read_u32(data: &[u8], cursor: &mut usize) -> Result { let end = cursor .checked_add(4) diff --git a/shine-solana/shine/tests/shine.ts b/shine-solana/shine/tests/shine.ts index 426da8c..1a1b82e 100644 --- a/shine-solana/shine/tests/shine.ts +++ b/shine-solana/shine/tests/shine.ts @@ -4,7 +4,6 @@ import { Ed25519Program, PublicKey, SYSVAR_INSTRUCTIONS_PUBKEY, - SystemProgram, Transaction, } from "@solana/web3.js"; import { createHash } from "crypto"; @@ -12,23 +11,36 @@ import { expect } from "chai"; import { Shine } from "../target/types/shine"; const MAGIC = Buffer.from("SHiNE", "utf8"); -const FORMAT_MAJOR = 1; +const FORMAT_MAJOR = 2; const FORMAT_MINOR = 0; -const RESERVED = Buffer.from([0, 0, 0, 0, 0]); const ZERO_HASH = Buffer.alloc(32, 0); -const KEY_STATUS_CREATED = 0; +const LAST_BLOCK_STATE_PREFIX = Buffer.from("SHiNE_LAST_BLOCK", "utf8"); +const BLOCK_TYPE_ROOT_KEY = 1; +const BLOCK_TYPE_DEVICE_KEY = 2; +const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3; +const BLOCK_TYPE_SERVER_PROFILE = 30; +const BLOCK_TYPE_ACCESS_SERVERS = 40; +const BLOCK_TYPE_TRUSTED_STATE = 50; +const BLOCK_VERSION_0 = 0; +const BLOCKCHAIN_TYPE_MAIN_USER = 1; const LIMIT_STEP = 10_000n; const START_BONUS_LIMIT = 100_000n; const USERS_ECONOMY_CONFIG_SEED = "shine_users_economy_config"; -const SHINE_PAYMENTS_PROGRAM_ID = new PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR"); -const SHINE_LOGIN_GUARD_PROGRAM_ID = new PublicKey("3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"); +const SHINE_PAYMENTS_PROGRAM_ID = new PublicKey( + "m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR" +); const SHINE_PAYMENTS_INFLOW_VAULT_SEED = "shine_payments_inflow_vault"; type MutableFields = { - blockchainKey: PublicKey; deviceKey: PublicKey; - chainNumber: number; + blockchainPublicKey: PublicKey; + blockchainName: string; + usedBytes: bigint; + lastBlockNumber: number; + lastBlockHash: Buffer; + lastBlockSignature: Buffer; + arweaveTxId: string; isServer: boolean; serverKey: PublicKey; serverAddress: string; @@ -40,17 +52,22 @@ type MutableFields = { type UnsignedRecord = { createdAtMs: bigint; updatedAtMs: bigint; - version: number; - prevHash: Buffer; + recordNumber: number; + prevRecordHash: Buffer; login: string; - rootKeyStatus: number; rootKey: PublicKey; - blockchainKeyStatus: number; - blockchainKey: PublicKey; - deviceKeyStatus: number; deviceKey: PublicKey; - chainNumber: number; - balance: bigint; + blockchain: { + blockchainType: number; + blockchainName: string; + blockchainPublicKey: PublicKey; + paidLimitBytes: bigint; + usedBytes: bigint; + lastBlockNumber: number; + lastBlockHash: Buffer; + lastBlockSignature: Buffer; + arweaveTxId: string; + }; isServer: boolean; serverKey: PublicKey; serverAddress: string; @@ -59,12 +76,6 @@ type UnsignedRecord = { trustedCount: number; }; -function u16le(v: number): Buffer { - const b = Buffer.alloc(2); - b.writeUInt16LE(v, 0); - return b; -} - function u32le(v: number): Buffer { const b = Buffer.alloc(4); b.writeUInt32LE(v, 0); @@ -77,10 +88,12 @@ function u64le(v: bigint): Buffer { return b; } -function serializeUnsignedRecord(r: UnsignedRecord): Buffer { - const loginBytes = Buffer.from(r.login, "utf8"); - const serverAddressBytes = Buffer.from(r.serverAddress, "utf8"); +function strBytes(value: string): Buffer { + const bytes = Buffer.from(value, "utf8"); + return Buffer.concat([Buffer.from([bytes.length]), bytes]); +} +function serializeUnsignedRecord(r: UnsignedRecord): Buffer { const out: Buffer[] = []; out.push(MAGIC); out.push(Buffer.from([FORMAT_MAJOR])); @@ -89,44 +102,48 @@ function serializeUnsignedRecord(r: UnsignedRecord): Buffer { out.push(u64le(r.createdAtMs)); out.push(u64le(r.updatedAtMs)); - out.push(u32le(r.version)); - out.push(r.prevHash); + out.push(u32le(r.recordNumber)); + out.push(r.prevRecordHash); + out.push(strBytes(r.login)); - out.push(Buffer.from([loginBytes.length])); - out.push(loginBytes); - - out.push(Buffer.from([r.rootKeyStatus])); + out.push(Buffer.from([r.isServer ? 6 : 5])); + out.push(Buffer.from([BLOCK_TYPE_ROOT_KEY, BLOCK_VERSION_0])); out.push(r.rootKey.toBuffer()); - out.push(Buffer.from([r.blockchainKeyStatus])); - out.push(r.blockchainKey.toBuffer()); - out.push(Buffer.from([r.deviceKeyStatus])); + out.push(Buffer.from([BLOCK_TYPE_DEVICE_KEY, BLOCK_VERSION_0])); out.push(r.deviceKey.toBuffer()); + out.push(Buffer.from([BLOCK_TYPE_BLOCKCHAIN_REGISTRY, BLOCK_VERSION_0, 1])); + out.push(Buffer.from([r.blockchain.blockchainType])); + out.push(strBytes(r.blockchain.blockchainName)); + out.push(r.blockchain.blockchainPublicKey.toBuffer()); + out.push(u64le(r.blockchain.paidLimitBytes)); + out.push(u64le(r.blockchain.usedBytes)); + out.push(u32le(r.blockchain.lastBlockNumber)); + out.push(r.blockchain.lastBlockHash); + out.push(r.blockchain.lastBlockSignature); + if (r.blockchain.arweaveTxId.length === 0) { + out.push(Buffer.from([0])); + } else { + out.push(Buffer.from([1])); + out.push(strBytes(r.blockchain.arweaveTxId)); + } - out.push(u16le(r.chainNumber)); - out.push(u64le(r.balance)); - - out.push(Buffer.from([r.isServer ? 1 : 0])); if (r.isServer) { + out.push(Buffer.from([BLOCK_TYPE_SERVER_PROFILE, BLOCK_VERSION_0, 1])); out.push(r.serverKey.toBuffer()); - out.push(Buffer.from([serverAddressBytes.length])); - out.push(serverAddressBytes); + out.push(strBytes(r.serverAddress)); out.push(Buffer.from([r.syncServers.length])); for (const s of r.syncServers) { - const sb = Buffer.from(s, "utf8"); - out.push(Buffer.from([sb.length])); - out.push(sb); + out.push(strBytes(s)); } } + out.push(Buffer.from([BLOCK_TYPE_ACCESS_SERVERS, BLOCK_VERSION_0])); out.push(Buffer.from([r.accessServers.length])); for (const s of r.accessServers) { - const sb = Buffer.from(s, "utf8"); - out.push(Buffer.from([sb.length])); - out.push(sb); + out.push(strBytes(s)); } - + out.push(Buffer.from([BLOCK_TYPE_TRUSTED_STATE, BLOCK_VERSION_0])); out.push(Buffer.from([r.trustedCount])); - out.push(RESERVED); const unsigned = Buffer.concat(out); const recordLen = unsigned.length + 64; @@ -138,6 +155,17 @@ function sha256(buf: Buffer): Buffer { return createHash("sha256").update(buf).digest(); } +function serializeLastBlockState(r: UnsignedRecord): Buffer { + return Buffer.concat([ + LAST_BLOCK_STATE_PREFIX, + strBytes(r.login), + strBytes(r.blockchain.blockchainName), + u32le(r.blockchain.lastBlockNumber), + r.blockchain.lastBlockHash, + u64le(r.blockchain.usedBytes), + ]); +} + function extractSigFromEdIx(ixData: Buffer): Buffer { const signatureOffset = ixData.readUInt16LE(2); return ixData.subarray(signatureOffset, signatureOffset + 64); @@ -163,23 +191,25 @@ describe("shine_users e2e", () => { SHINE_PAYMENTS_PROGRAM_ID ); - const economyAi = await provider.connection.getAccountInfo(usersEconomyConfigPda); + const economyAi = await provider.connection.getAccountInfo( + usersEconomyConfigPda + ); if (!economyAi) { await program.methods .initUsersEconomyConfig() .accounts({ signer: provider.wallet.publicKey, usersEconomyConfigPda, - systemProgram: SystemProgram.programId, }) .rpc(); } const root = anchor.web3.Keypair.generate(); - const blockchainKey = anchor.web3.Keypair.generate().publicKey; + const blockchain = anchor.web3.Keypair.generate(); const deviceKey = anchor.web3.Keypair.generate().publicKey; const serverKey1 = anchor.web3.Keypair.generate().publicKey; const serverKey2 = anchor.web3.Keypair.generate().publicKey; + const blockchainName = `${login}-001`; const createdAtMs = BigInt(Date.now()); const additionalLimitCreate = 20_000n; @@ -188,17 +218,22 @@ describe("shine_users e2e", () => { const createRecord: UnsignedRecord = { createdAtMs, updatedAtMs: createdAtMs, - version: 0, - prevHash: ZERO_HASH, + recordNumber: 0, + prevRecordHash: ZERO_HASH, login, - rootKeyStatus: KEY_STATUS_CREATED, rootKey: root.publicKey, - blockchainKeyStatus: KEY_STATUS_CREATED, - blockchainKey, - deviceKeyStatus: KEY_STATUS_CREATED, deviceKey, - chainNumber: 1, - balance: START_BONUS_LIMIT + additionalLimitCreate, + blockchain: { + blockchainType: BLOCKCHAIN_TYPE_MAIN_USER, + blockchainName, + blockchainPublicKey: blockchain.publicKey, + paidLimitBytes: START_BONUS_LIMIT + additionalLimitCreate, + usedBytes: 0n, + lastBlockNumber: 0, + lastBlockHash: ZERO_HASH, + lastBlockSignature: Buffer.alloc(64, 0), + arweaveTxId: "", + }, isServer: true, serverKey: serverKey1, serverAddress: "https://srv-1.local", @@ -206,6 +241,14 @@ describe("shine_users e2e", () => { accessServers: ["access_srv_1"], trustedCount: 0, }; + const createLastBlockHash = sha256(serializeLastBlockState(createRecord)); + const createLastBlockEdIx = Ed25519Program.createInstructionWithPrivateKey({ + privateKey: blockchain.secretKey, + message: createLastBlockHash, + }); + createRecord.blockchain.lastBlockSignature = extractSigFromEdIx( + Buffer.from(createLastBlockEdIx.data) + ); const createUnsigned = serializeUnsignedRecord(createRecord); const createHash = sha256(createUnsigned); @@ -222,9 +265,16 @@ describe("shine_users e2e", () => { createdAtMs: new anchor.BN(createdAtMs.toString()), additionalLimit: new anchor.BN(additionalLimitCreate.toString()), fields: { - blockchainKey, deviceKey, - chainNumber: 1, + blockchainPublicKey: blockchain.publicKey, + blockchainName, + usedBytes: new anchor.BN( + createRecord.blockchain.usedBytes.toString() + ), + lastBlockNumber: createRecord.blockchain.lastBlockNumber, + lastBlockHash: createRecord.blockchain.lastBlockHash, + lastBlockSignature: createRecord.blockchain.lastBlockSignature, + arweaveTxId: "", isServer: true, serverKey: serverKey1, serverAddress: "https://srv-1.local", @@ -237,15 +287,16 @@ describe("shine_users e2e", () => { .accounts({ signer: provider.wallet.publicKey, userPda, - systemProgram: SystemProgram.programId, inflowVault: inflowVaultPda, instructions: SYSVAR_INSTRUCTIONS_PUBKEY, usersEconomyConfigPda, - loginGuardProgram: SHINE_LOGIN_GUARD_PROGRAM_ID, }) .instruction(); - await provider.sendAndConfirm(new Transaction().add(createEdIx, createIx), []); + await provider.sendAndConfirm( + new Transaction().add(createLastBlockEdIx, createEdIx, createIx), + [] + ); const createAcc = await provider.connection.getAccountInfo(userPda); expect(createAcc).not.eq(null); @@ -257,17 +308,23 @@ describe("shine_users e2e", () => { const updateRecord: UnsignedRecord = { createdAtMs, updatedAtMs: createdAtMs + 1_000n, - version: 1, - prevHash: sha256(createUnsigned), + recordNumber: 1, + prevRecordHash: sha256(createUnsigned), login, - rootKeyStatus: KEY_STATUS_CREATED, rootKey: root.publicKey, - blockchainKeyStatus: KEY_STATUS_CREATED, - blockchainKey: anchor.web3.Keypair.generate().publicKey, - deviceKeyStatus: KEY_STATUS_CREATED, deviceKey: anchor.web3.Keypair.generate().publicKey, - chainNumber: 1, - balance: START_BONUS_LIMIT + additionalLimitCreate + additionalLimitUpdate, + blockchain: { + blockchainType: BLOCKCHAIN_TYPE_MAIN_USER, + blockchainName, + blockchainPublicKey: blockchain.publicKey, + paidLimitBytes: + START_BONUS_LIMIT + additionalLimitCreate + additionalLimitUpdate, + usedBytes: 512n, + lastBlockNumber: 1, + lastBlockHash: sha256(Buffer.from("first-shine-block")), + lastBlockSignature: Buffer.alloc(64, 0), + arweaveTxId: "", + }, isServer: true, serverKey: serverKey2, serverAddress: "https://srv-2.local", @@ -275,6 +332,14 @@ describe("shine_users e2e", () => { accessServers: ["access_srv_2", "access_srv_3"], trustedCount: 0, }; + const updateLastBlockHash = sha256(serializeLastBlockState(updateRecord)); + const updateLastBlockEdIx = Ed25519Program.createInstructionWithPrivateKey({ + privateKey: blockchain.secretKey, + message: updateLastBlockHash, + }); + updateRecord.blockchain.lastBlockSignature = extractSigFromEdIx( + Buffer.from(updateLastBlockEdIx.data) + ); const updateUnsigned = serializeUnsignedRecord(updateRecord); const updateHash = sha256(updateUnsigned); @@ -294,9 +359,16 @@ describe("shine_users e2e", () => { prevHash: sha256(createUnsigned), additionalLimit: new anchor.BN(additionalLimitUpdate.toString()), fields: { - blockchainKey: updateRecord.blockchainKey, deviceKey: updateRecord.deviceKey, - chainNumber: 1, + blockchainPublicKey: updateRecord.blockchain.blockchainPublicKey, + blockchainName, + usedBytes: new anchor.BN( + updateRecord.blockchain.usedBytes.toString() + ), + lastBlockNumber: updateRecord.blockchain.lastBlockNumber, + lastBlockHash: updateRecord.blockchain.lastBlockHash, + lastBlockSignature: updateRecord.blockchain.lastBlockSignature, + arweaveTxId: "", isServer: true, serverKey: serverKey2, serverAddress: "https://srv-2.local", @@ -309,14 +381,16 @@ describe("shine_users e2e", () => { .accounts({ signer: provider.wallet.publicKey, userPda, - systemProgram: SystemProgram.programId, inflowVault: inflowVaultPda, instructions: SYSVAR_INSTRUCTIONS_PUBKEY, usersEconomyConfigPda, }) .instruction(); - await provider.sendAndConfirm(new Transaction().add(updateEdIx, updateIx), []); + await provider.sendAndConfirm( + new Transaction().add(updateLastBlockEdIx, updateEdIx, updateIx), + [] + ); const updatedAcc = await provider.connection.getAccountInfo(userPda); expect(updatedAcc).not.eq(null); diff --git a/shine-solana/shine/tsconfig.json b/shine-solana/shine/tsconfig.json index 1235e38..c111163 100644 --- a/shine-solana/shine/tsconfig.json +++ b/shine-solana/shine/tsconfig.json @@ -2,9 +2,9 @@ "compilerOptions": { "types": ["mocha", "chai"], "typeRoots": ["./node_modules/@types"], - "lib": ["es2015"], + "lib": ["es2020"], "module": "commonjs", - "target": "es6", + "target": "es2020", "esModuleInterop": true } }