diff --git a/Dev_Docs/Pending_Features/2026-06-21_1735_esp32_wallet_selector_and_extension_rpc.md b/Dev_Docs/Pending_Features/2026-06-21_1735_esp32_wallet_selector_and_extension_rpc.md new file mode 100644 index 0000000..f5e30a2 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-21_1735_esp32_wallet_selector_and_extension_rpc.md @@ -0,0 +1,27 @@ +# ESP32 выбор кошелька и wallet RPC для browser extension + +- краткое описание: + - в ESP32 заменён домашний блок `баланс + QR` на единый вход в экран кошелька; + - добавлен выбор активного кошелька `DeviceKey / RootKey / Custom`; + - для browser extension добавлен первый RPC `get_wallet_public_key` через существующий `wallet-session` и `CallSignalToSession`; + - в popup расширения добавлен запрос текущего кошелька и копирование `publicKeyBase58`. + +- что проверять: + - на ESP32 после ввода секрета на главном экране видна кнопка `Кошелёк: ...`; + - экран `WALLET` открывается и показывает текущий тип кошелька; + - экран `WALLET_SELECT` переключает `DeviceKey`, `RootKey` и `Custom`; + - для `Custom` открывается ввод имени и после сохранения derivation работает; + - `Показать баланс кошелька` читает баланс именно активного кошелька; + - `Показать QR-код кошелька` показывает QR и адрес именно активного кошелька; + - browser extension после подключения wallet-session может запросить текущий кошелёк у ESP32; + - extension показывает тип кошелька, полный `publicKeyBase58`, результат проверки через PDA и копирует ключ в буфер; + - для `dev.key` и `root.key` проверка через PDA даёт ожидаемое совпадение. + +- ожидаемый результат: + - активный кошелёк на ESP32 реально влияет на баланс, QR и ответ `get_wallet_public_key`; + - browser extension получает ответ без ручного ввода `walletSelector`; + - homeserver выбирается из опубликованных в PDA sessions и запрос приходит в нужное устройство; + - копирование ключа из extension работает. + +- статус: + - pending diff --git a/Dev_Docs/Pending_Features/2026-06-21_2045_esp32_english_ui_and_trusted_login_fix.md b/Dev_Docs/Pending_Features/2026-06-21_2045_esp32_english_ui_and_trusted_login_fix.md new file mode 100644 index 0000000..023531e --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-21_2045_esp32_english_ui_and_trusted_login_fix.md @@ -0,0 +1,18 @@ +# ESP32 English UI and trusted login fix + +- Краткое описание: + переведён экранный UI ESP32 homeserver на английский язык; добавлена локальная инструкция для AGENTS по ограничению кириллицы; исправлено падение `StartTrustedDeviceLogin`, когда у пользователя ещё нет записи `esp_pairing_settings`. + +- Что проверять: + 1. На ESP32 открыть `HOME`, `WALLET`, `SELECT WALLET`, `WALLET QR` и убедиться, что пользовательские строки на экране только на английском. + 2. Проверить сценарий выбора `DeviceKey`, `RootKey`, `Custom` и чтение баланса/QR после переключения. + 3. В расширении открыть popup, запустить `Подключить` для логина, у которого ещё не настраивался trusted-device login. + 4. Убедиться, что `StartTrustedDeviceLogin` больше не падает с `NullPointerException`. + 5. После создания pairing-запроса проверить, что homeserver/UI может его увидеть и обработать как раньше. + +- Ожидаемый результат: + - ESP32 UI читается на устройстве без кириллических строк. + - Подключение через trusted-device login стартует без server-side `INTERNAL_HANDLER_ERROR`, даже если настройки pairing ещё ни разу не сохранялись. + +- Статус: + `pending` diff --git a/Dev_Docs/Pending_Features/2026-06-21_2125_browser_wallet_side_panel.md b/Dev_Docs/Pending_Features/2026-06-21_2125_browser_wallet_side_panel.md new file mode 100644 index 0000000..ca1d167 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-21_2125_browser_wallet_side_panel.md @@ -0,0 +1,20 @@ +# Browser wallet side panel + +- Краткое описание: + browser extension `SHiNE Wallet` переведён с toolbar popup на штатный Chromium side panel. + +- Что проверять: + 1. Перезагрузить unpacked extension в Chromium-браузере. + 2. Нажать на иконку `SHiNE Wallet` в toolbar. + 3. Убедиться, что открывается side panel, а не всплывающий popup. + 4. Проверить, что панель можно держать открытой при навигации по сайтам. + 5. Проверить сценарии `Подключить`, `Отключить`, `Обновить устройства`, `Запросить кошелёк`. + 6. Проверить, что сторона панели управляется браузером, а UI корректно выглядит и слева, и справа. + +- Ожидаемый результат: + - расширение открывается в штатной боковой панели Chromium; + - UI работает так же, как раньше в popup; + - панель остаётся доступной как постоянная колонка браузера. + +- Статус: + `pending` diff --git a/Dev_Docs/Pending_Features/2026-06-21_2235_wallet_provider_and_esp_sign_transaction.md b/Dev_Docs/Pending_Features/2026-06-21_2235_wallet_provider_and_esp_sign_transaction.md new file mode 100644 index 0000000..55771e1 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-21_2235_wallet_provider_and_esp_sign_transaction.md @@ -0,0 +1,23 @@ +# Wallet provider and ESP sign_transaction + +- Краткое описание: + browser extension `SHiNE Wallet` теперь внедряет `window.solana` для сайтов и умеет выполнять `connect` и `signTransaction`; подпись транзакции уходит на ESP32 через wallet RPC `sign_transaction`, а подтверждение делается на устройстве. + +- Что проверять: + 1. Перезагрузить unpacked extension в Chromium. + 2. Открыть сайт/тестовую страницу, которая вызывает `window.solana.connect()`. + 3. Подтвердить подключение кошелька и убедиться, что сайт получает публичный ключ. + 4. Открыть сайт/тестовую страницу, которая вызывает `window.solana.signTransaction(...)`. + 5. Убедиться, что на ESP32 открывается экран `SIGN REQUEST` с комментарием. + 6. Проверить оба варианта: + - `APPROVE` возвращает сайту подписанную транзакцию; + - `REJECT` возвращает отказ. + 7. Проверить сценарии для `DeviceKey`, `RootKey`, `Custom`. + +- Ожидаемый результат: + - сайт может подключить кошелёк через provider расширения; + - транзакция подписывается только после подтверждения на ESP32; + - отказ на ESP32 корректно доходит до сайта. + +- Статус: + `pending` diff --git a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md index 61a4f81..2022536 100644 --- a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md +++ b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md @@ -110,3 +110,9 @@ ```text sha256$ ``` + +## 8. Связанный документ по внешнему кошельку + +Для отдельного RPC-взаимодействия между браузерным wallet-расширением и ESP32 см. документ: + +- [Формат_взаимодействия_внешнего_кошелька_и_ESP32.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md) diff --git a/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md b/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md new file mode 100644 index 0000000..137ac30 --- /dev/null +++ b/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md @@ -0,0 +1,311 @@ +# Формат взаимодействия внешнего кошелька и ESP32 + +Этот документ фиксирует первый этап формата взаимодействия между внешним браузерным wallet-расширением SHiNE и устройством `ESP32-S3-Touch-AMOLED-2.16`. + +Документ описывает: + +- как расширение получает текущий активный публичный ключ кошелька с ESP32; +- как расширение отправляет на ESP32 запрос подписи транзакции; +- что именно считается активным кошельком на ESP32; +- какие проверки и UI-реакции ожидаются в браузерном расширении и на устройстве; +- какие ограничения действуют в текущей версии протокола. + +## 1. Общая идея + +Устройство ESP32 хранит `master secret` пользователя и локально умеет выводить несколько кошельков из одного секрета. + +На устройстве в UI пользователь выбирает текущий активный кошелёк: + +- `dev.key` +- `root.key` +- `custom` + +Для `custom` используется derivation: + +```text +sha256(base64(secret32) + "|wallet." + customName) +``` + +Браузерное расширение не указывает ESP32, какой кошелёк нужно вернуть в первом запросе. Оно просто спрашивает: + +```text +какой кошелёк сейчас активен на устройстве +``` + +ESP32 возвращает: + +- тип текущего активного кошелька; +- его публичный ключ `Base58`. + +## 2. Транспорт и маршрут + +Первая версия формата использует уже существующую `wallet-session` браузерного расширения. + +Схема маршрута: + +`browser extension -> SHiNE server -> homeserver session on ESP32 -> SHiNE server -> browser extension` + +В первой версии: + +- отдельная цифровая подпись payload не добавляется; +- отдельное E2E-шифрование для wallet RPC не добавляется; +- используется существующая авторизованная `wallet-session`, транспорт `WSS` и server-side маршрут через уже существующую операцию `CallSignalToSession`. + +## 3. Запрос текущего публичного ключа кошелька + +### 3.1. Назначение + +Операция нужна, чтобы браузерное расширение могло узнать, какой кошелёк сейчас выбран на ESP32, и показать его пользователю перед дальнейшими действиями. + +### 3.2. Формат запроса + +```json +{ + "v": 1, + "operation": "get_wallet_public_key", + "requestId": "1718998123456-482193", + "timeMs": 1718998123456 +} +``` + +### 3.3. Поля запроса + +- `v` — версия формата wallet RPC. Для текущего варианта: `1`. +- `operation` — строка операции. Для текущего запроса: `get_wallet_public_key`. +- `requestId` — идентификатор запроса, уникальный в пределах сеанса расширения. Рекомендуемый формат: + `timeMs-random`. +- `timeMs` — локальное время отправителя в миллисекундах. + +### 3.4. Поведение ESP32 + +При получении такого запроса ESP32: + +1. смотрит, какой кошелёк сейчас выбран в локальном UI; +2. вычисляет или берёт уже подготовленный публичный ключ именно этого активного кошелька; +3. возвращает тип кошелька и его `publicKeyBase58`. + +Запрос не содержит: + +- `walletSelector`; +- `customName`; +- `targetSessionName`. + +Они намеренно не входят в первую версию этого запроса. + +## 4. Формат ответа + +```json +{ + "v": 1, + "op": "get_wallet_public_key_result", + "requestId": "1718998123456-482193", + "ok": true, + "wallet": { + "type": "custom", + "publicKeyBase58": "...." + }, + "timeMs": 1718998123999 +} +``` + +## 5. Поля ответа + +- `v` — версия формата ответа. Сейчас `1`. +- `op` — строка результата операции. Сейчас `get_wallet_public_key_result`. +- `requestId` — должен совпадать с `requestId` исходного запроса. +- `ok` — признак успешного результата. +- `wallet.type` — тип активного кошелька: + - `dev.key` + - `root.key` + - `custom` +- `wallet.publicKeyBase58` — публичный ключ активного кошелька в `Base58`. +- `timeMs` — время формирования ответа на стороне ESP32 в миллисекундах. + +## 6. Ошибки первой версии + +Минимальный формат ошибки в первой версии допускается таким: + +```json +{ + "v": 1, + "op": "get_wallet_public_key_result", + "requestId": "1718998123456-482193", + "ok": false, + "error": "wallet_unavailable", + "timeMs": 1718998123999 +} +``` + +Рекомендуемые коды ошибок первой версии: + +- `wallet_unavailable` — на устройстве нельзя получить текущий кошелёк; +- `secret_not_configured` — на устройстве ещё нет корректно сохранённого секрета; +- `wallet_type_unknown` — выбранный локальный тип кошелька не распознан; +- `internal_error` — прочая локальная ошибка устройства. + +## 7. Правила для браузерного расширения + +После ответа `ok=true` расширение должно: + +1. показать пользователю тип кошелька; +2. показать полный `publicKeyBase58`; +3. дать кнопку копирования ключа в буфер; +4. сохранить этот ключ как текущий ключ устройства для следующей операции подписи. + +### 7.1. Проверка через PDA Solana + +Расширение уже знает публичные ключи пользователя из Solana PDA. Поэтому оно может дополнительно проверить ответ ESP32: + +- если `wallet.type = dev.key`, то `publicKeyBase58` должен совпасть с `deviceKey`, прочитанным из PDA; +- если `wallet.type = root.key`, то `publicKeyBase58` должен совпасть с `rootKey`, прочитанным из PDA; +- если `wallet.type = custom`, такой проверки по PDA в первой версии нет. + +При несовпадении для `dev.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA. + +## 8. Ожидаемое поведение UI расширения + +### 8.1. Общий вид popup + +Popup браузерного расширения должен быть узким и вытянутым по вертикали. + +### 8.2. Состояние без подключения + +Если `wallet-session` ещё не подключена: + +- показывается кнопка `Подключить`; +- по нажатию открывается экран подключения, близкий по смыслу к сценарию `Войти через другое устройство`; +- пользователь вводит логин устройства и получает код подключения. + +### 8.3. Состояние после подключения + +Если `wallet-session` уже подключена: + +- показывается статус `Подключено`; +- остаётся выбор homeserver; +- появляется кнопка запроса текущего кошелька; +- появляется кнопка `Отключить`. + +### 8.4. Подключение кошелька с сайта + +Когда сайт просит подключить кошелёк через расширение, расширение должно вести себя как обычный wallet extension: + +1. показать пользователю подтверждение подключения; +2. показать, какой именно кошелёк будет подключён; +3. после подтверждения пользователя завершить подключение; +4. если пользователь отказался, не подключать кошелёк к сайту. + +## 9. Запрос подписи транзакции + +### 9.1. Назначение + +Операция нужна, чтобы браузерное расширение могло запросить у ESP32 подпись Solana-транзакции текущим активным кошельком. + +Расширение передаёт: + +- публичный ключ, которым ожидается подпись; +- сериализованную транзакцию; +- комментарий, который должен быть показан на экране ESP32. + +ESP32: + +1. показывает пользователю запрос подтверждения; +2. показывает комментарий к подписи; +3. после нажатия `APPROVE` или `REJECT` возвращает ответ в расширение. + +### 9.2. Формат запроса + +```json +{ + "v": 1, + "operation": "sign_transaction", + "requestId": "1718998123456-482193", + "timeMs": 1718998123456, + "publicKeyBase58": "....", + "transactionBase64": "....", + "comment": "Site https://example.com requested transaction signature" +} +``` + +### 9.3. Поля запроса + +- `v` — версия wallet RPC. Сейчас `1`. +- `operation` — строка операции: `sign_transaction`. +- `requestId` — идентификатор запроса. +- `timeMs` — время отправки на стороне расширения. +- `publicKeyBase58` — публичный ключ, от которого ожидается подпись. +- `transactionBase64` — сериализованная Solana transaction в `base64`. +- `comment` — короткое текстовое описание, которое ESP32 показывает пользователю при запросе подписи. + +### 9.4. Поведение ESP32 + +При получении такого запроса ESP32: + +1. сравнивает `publicKeyBase58` с публичным ключом текущего активного выбранного кошелька; +2. если ключ не совпадает, сразу возвращает ошибку `wallet_mismatch`; +3. если ключ совпадает, показывает отдельный экран подтверждения подписи; +4. на экране показывает: + - каким кошельком будет выполнена подпись; + - комментарий `comment`; + - кнопки `APPROVE` и `REJECT`; +5. если пользователь подтверждает подпись, ESP32 подписывает транзакцию и возвращает результат; +6. если пользователь отклоняет, ESP32 возвращает `rejected_by_user`. + +## 10. Формат ответа на подпись + +```json +{ + "v": 1, + "op": "sign_transaction_result", + "requestId": "1718998123456-482193", + "ok": true, + "publicKeyBase58": "....", + "signatureBase58": "....", + "signedTransactionBase64": "....", + "timeMs": 1718998123999 +} +``` + +Если пользователь отклонил запрос: + +```json +{ + "v": 1, + "op": "sign_transaction_result", + "requestId": "1718998123456-482193", + "ok": false, + "error": "rejected_by_user", + "timeMs": 1718998123999 +} +``` + +Рекомендуемые ошибки для `sign_transaction`: + +- `rejected_by_user` +- `wallet_unavailable` +- `wallet_mismatch` +- `transaction_base64_invalid` +- `transaction_sign_failed` +- `bad_request` + +## 11. Подключение кошелька с сайта + +При вызове сайта `connect wallet` расширение должно вести себя как обычный wallet extension: + +1. запросить подтверждение у пользователя в браузере; +2. получить текущий публичный ключ с ESP32; +3. вернуть сайту `publicKey` текущего активного кошелька. + +Для `signTransaction` расширение: + +1. получает транзакцию от сайта; +2. пересылает её на ESP32 через `sign_transaction`; +3. ждёт решение пользователя на устройстве; +4. возвращает браузеру уже подписанную транзакцию. + +## 12. Ограничения текущей версии + +- запрос возвращает только текущий активный кошелёк, а не список всех кошельков; +- выбор типа кошелька делается только на самом ESP32; +- отдельная цифровая подпись ответа пока не используется; +- отдельное E2E-шифрование wallet RPC пока не используется; +- `custom`-кошельки в первой версии не сверяются с PDA. diff --git a/ESP32/AGENTS.md b/ESP32/AGENTS.md new file mode 100644 index 0000000..5afda45 --- /dev/null +++ b/ESP32/AGENTS.md @@ -0,0 +1,11 @@ +# AGENTS for ESP32 + +## Язык UI + +- Для ESP32-скетчей и экранного UI использовать английский язык. +- Русский текст на экране ESP32 пока не поддерживается корректно: шрифтовой путь для кириллицы не считается рабочим. +- Если меняется UI-скетч, все пользовательские строки на экране должны оставаться английскими, пока ограничение не снято отдельной задачей. + +## Синхронизация со спецификацией + +- При изменении экранов, кнопок, переходов, статусов или текстов обязательно обновлять соответствующую спецификацию в `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/`. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 954cc03..2a204d1 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -92,7 +92,10 @@ static const char *kSessionClientPlatformEsp32 = "ESP32"; enum Screen { SCREEN_HOME, + SCREEN_WALLET, + SCREEN_WALLET_SELECT, SCREEN_WALLET_QR, + SCREEN_WALLET_SIGN_REQUEST, SCREEN_SETTINGS_MENU, SCREEN_PAIRING_REQUESTS, SCREEN_PAIRING_REQUEST_DETAIL, @@ -123,7 +126,14 @@ enum SwipeDirection { enum ActionId { ACTION_NONE, ACTION_OPEN_SETTINGS, + ACTION_OPEN_WALLET, + ACTION_WALLET_SELECT, + ACTION_WALLET_SELECT_DEVICE, + ACTION_WALLET_SELECT_ROOT, + ACTION_WALLET_SELECT_CUSTOM, ACTION_OPEN_WALLET_QR, + ACTION_WALLET_SIGN_APPROVE, + ACTION_WALLET_SIGN_REJECT, ACTION_OPEN_PAIRING_REQUESTS, ACTION_OPEN_WIFI, ACTION_OPEN_SERVER, @@ -173,10 +183,17 @@ enum EditContext { EDIT_CONTEXT_SHINE_SERVER, EDIT_CONTEXT_LOGIN, EDIT_CONTEXT_HOMESERVER, + EDIT_CONTEXT_WALLET_CUSTOM, EDIT_CONTEXT_SECRET_MANUAL, EDIT_CONTEXT_SECRET_GENERATE_PASSWORD, }; +enum WalletSelectionType { + WALLET_SELECTION_DEVICE, + WALLET_SELECTION_ROOT, + WALLET_SELECTION_CUSTOM, +}; + enum KeyboardMode { KEYBOARD_MODE_ALPHA, KEYBOARD_MODE_SYMBOLS, @@ -242,6 +259,27 @@ struct PairingRequestUiItem { uint64_t expiresAtMs = 0; }; +struct PendingWalletRpcRequest { + String fromLogin; + String fromSessionId; + String callId; + int type = 0; + String data; +}; + +struct ActiveWalletSignRequest { + bool active = false; + String fromLogin; + String fromSessionId; + String callId; + String requestId; + String publicKeyBase58; + String transactionBase64; + String comment; + uint64_t requestedAtMs = 0; + Screen returnScreen = SCREEN_HOME; +}; + static lv_disp_draw_buf_t gDrawBuf; static lv_color_t *gBuf1 = nullptr; static lv_color_t *gBuf2 = nullptr; @@ -290,7 +328,7 @@ static bool gSecretConfigured = false; static String gSecretBase58; static uint8_t gSecretBytes[32] = {}; static String gAccountStatusMessage = "Edit account fields"; -static String gBalanceStatusMessage = "Balance: tap to load"; +static String gBalanceStatusMessage = "Wallet: tap to load balance"; static bool gBalanceAutoRefreshPending = false; static unsigned long gLastBalanceAutoRefreshAttemptMs = 0; static bool gWifiKnownGood = false; @@ -360,6 +398,15 @@ static String gDevicePubB58; static String gDevicePrivB58; static String gHomeserverPubB58; static String gHomeserverPrivB58; +static WalletSelectionType gSelectedWalletType = WALLET_SELECTION_DEVICE; +static String gCustomWalletName; +static String gCustomWalletPubB58; +static String gCustomWalletPrivB58; +static std::vector gPendingWalletRpcRequests; +static const int kWalletRpcSignalTypeRequest = 9100; +static const int kWalletRpcSignalTypeResponse = 9101; +static ActiveWalletSignRequest gActiveWalletSignRequest; +static String gWalletSignStatusMessage; static EditContext gEditContext = EDIT_CONTEXT_NONE; static Screen gEditReturnScreen = SCREEN_HOME; @@ -405,6 +452,18 @@ static void keepCursorAtEnd(); static void restoreTextareaFromEditValue(); static void refreshDerivedKeys(); static void clearDerivedKeys(); +static String selectedWalletDisplayName(); +static String selectedWalletTypeCode(); +static String selectedWalletPublicKeyB58(); +static String selectedWalletPrivateKeyB58(); +static String selectedWalletDerivationSuffix(); +static bool selectedWalletAvailable(); +static void refreshSelectedWalletBalanceState(); +static bool loadSelectedWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut); +static bool loadBalanceLamportsForAddress(const String &address, uint64_t &lamportsOut, String &messageOut); +static bool queueIncomingWalletRpcRequest(const String &frame); +static void processPendingWalletRpcRequests(); +static void pumpShineIncomingFrames(uint32_t maxFrames = 3); static String loginDisplayValue(); static String homeserverDisplayValue(); static String homeSecretStatus(); @@ -525,6 +584,24 @@ static std::vector buildUpdateLegacyMessage( const std::vector &updateData); static bool signMessageEd25519(const std::vector &message, const uint8_t secretKey[64], uint8_t signature[64]); static String encodeTransactionBase64(const uint8_t signature[64], const std::vector &message); +static bool shortVecDecode(const std::vector &bytes, size_t &offset, uint32_t &valueOut); +static bool parseSolanaSignerIndex(const std::vector &txBytes, const uint8_t targetPub32[32], uint32_t &signerIndexOut, size_t &messageOffsetOut, String &errorOut); +static bool signSelectedWalletTransactionBase64(const String &publicKeyBase58, + const String &txBase64, + String &signedTxBase64Out, + String &signatureBase58Out, + String &errorOut); +static bool sendWalletRpcResponse(const String &toLogin, + const String &targetSessionId, + const String &callId, + const String &responseData); +static void queueWalletSignRequest(const PendingWalletRpcRequest &item, + const String &requestId, + const String &publicKeyBase58, + const String &transactionBase64, + const String &comment); +static void clearWalletSignRequest(); +static bool respondToActiveWalletSignRequest(bool approved); static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut); static bool registerHomeserverOnSolana(String &messageOut); static void executeRegisterAccountFlow(const char *triggerSource, bool showResultScreen); @@ -1147,6 +1224,8 @@ static void clearDerivedKeys() { gDevicePrivB58 = ""; gHomeserverPubB58 = ""; gHomeserverPrivB58 = ""; + gCustomWalletPubB58 = ""; + gCustomWalletPrivB58 = ""; } static void refreshDerivedKeys() { @@ -1158,6 +1237,87 @@ static void refreshDerivedKeys() { deriveKeyPairFromSecretSuffix(gSecretBytes, "bch.key", gBlockchainPubB58, gBlockchainPrivB58); deriveKeyPairFromSecretSuffix(gSecretBytes, "dev.key", gDevicePubB58, gDevicePrivB58); deriveKeyPairFromSecretSuffix(gSecretBytes, homeserverKeySuffix(), gHomeserverPubB58, gHomeserverPrivB58); + String customName = gCustomWalletName; + customName.trim(); + if (!customName.isEmpty()) { + deriveKeyPairFromSecretSuffix(gSecretBytes, String("wallet.") + customName, gCustomWalletPubB58, gCustomWalletPrivB58); + } +} + +static String selectedWalletDisplayName() { + switch (gSelectedWalletType) { + case WALLET_SELECTION_ROOT: + return "RootKey"; + case WALLET_SELECTION_CUSTOM: { + String customName = gCustomWalletName; + customName.trim(); + return customName.isEmpty() ? String("Custom") : customName; + } + case WALLET_SELECTION_DEVICE: + default: + return "DeviceKey"; + } +} + +static String selectedWalletTypeCode() { + switch (gSelectedWalletType) { + case WALLET_SELECTION_ROOT: + return "root.key"; + case WALLET_SELECTION_CUSTOM: + return "custom"; + case WALLET_SELECTION_DEVICE: + default: + return "dev.key"; + } +} + +static String selectedWalletDerivationSuffix() { + switch (gSelectedWalletType) { + case WALLET_SELECTION_ROOT: + return "root.key"; + case WALLET_SELECTION_CUSTOM: { + String customName = gCustomWalletName; + customName.trim(); + return customName.isEmpty() ? String("") : String("wallet.") + customName; + } + case WALLET_SELECTION_DEVICE: + default: + return "dev.key"; + } +} + +static String selectedWalletPublicKeyB58() { + switch (gSelectedWalletType) { + case WALLET_SELECTION_ROOT: + return gRootPubB58; + case WALLET_SELECTION_CUSTOM: + return gCustomWalletPubB58; + case WALLET_SELECTION_DEVICE: + default: + return gDevicePubB58; + } +} + +static String selectedWalletPrivateKeyB58() { + switch (gSelectedWalletType) { + case WALLET_SELECTION_ROOT: + return gRootPrivB58; + case WALLET_SELECTION_CUSTOM: + return gCustomWalletPrivB58; + case WALLET_SELECTION_DEVICE: + default: + return gDevicePrivB58; + } +} + +static bool selectedWalletAvailable() { + return !selectedWalletPublicKeyB58().isEmpty(); +} + +static void refreshSelectedWalletBalanceState() { + gBalanceStatusMessage = selectedWalletAvailable() ? "Wallet: tap to load balance" : "Wallet: secret not set"; + gBalanceAutoRefreshPending = selectedWalletAvailable(); + gLastBalanceAutoRefreshAttemptMs = 0; } static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]) { @@ -1341,7 +1501,7 @@ static String formatSolValue(uint64_t lamports) { uint64_t whole = lamports / 1000000000ULL; uint64_t frac = (lamports % 1000000000ULL) / 1000000ULL; char out[48]; - snprintf(out, sizeof(out), "Balance: %llu.%03llu SOL", + snprintf(out, sizeof(out), "Wallet: %llu.%03llu SOL", (unsigned long long)whole, (unsigned long long)frac); return String(out); @@ -1349,7 +1509,7 @@ static String formatSolValue(uint64_t lamports) { static bool refreshWalletBalance(String &messageOut) { uint64_t lamports = 0; - if (!loadWalletBalanceLamports(lamports, messageOut)) { + if (!loadSelectedWalletBalanceLamports(lamports, messageOut)) { gBalanceStatusMessage = messageOut; return false; } @@ -1360,27 +1520,35 @@ static bool refreshWalletBalance(String &messageOut) { } static bool loadWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut) { + return loadBalanceLamportsForAddress(gDevicePubB58, lamportsOut, messageOut); +} + +static bool loadSelectedWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut) { + return loadBalanceLamportsForAddress(selectedWalletPublicKeyB58(), lamportsOut, messageOut); +} + +static bool loadBalanceLamportsForAddress(const String &address, uint64_t &lamportsOut, String &messageOut) { messageOut = ""; lamportsOut = 0; if (WiFi.status() != WL_CONNECTED) { - messageOut = "Balance: Wi-Fi not connected"; + messageOut = "Wallet: Wi-Fi not connected"; return false; } - if (gDevicePubB58.isEmpty()) { - messageOut = "Balance: secret not set"; + if (address.isEmpty()) { + messageOut = "Wallet: unavailable"; return false; } int code = -1; String payload; - String req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\",\"params\":[\"" + gDevicePubB58 + "\",{\"commitment\":\"confirmed\"}]}"; + String req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\",\"params\":[\"" + address + "\",{\"commitment\":\"confirmed\"}]}"; if (!httpPostJson(gSolanaRpcUrl, req, code, payload)) { - messageOut = "Balance: RPC unavailable"; + messageOut = "Wallet: RPC unavailable"; return false; } if (!jsonInt64Field(payload, "value", lamportsOut)) { - messageOut = "Balance: failed to load"; + messageOut = "Wallet: balance unavailable"; return false; } @@ -1411,14 +1579,16 @@ static String shineHomeRichLine() { } static String walletQrUri() { - if (gDevicePubB58.isEmpty()) { + String publicKey = selectedWalletPublicKeyB58(); + if (publicKey.isEmpty()) { return ""; } - return String("solana:") + gDevicePubB58; + return String("solana:") + publicKey; } static String walletQrAddressLine() { - return gDevicePubB58.isEmpty() ? String("Wallet not set") : gDevicePubB58; + String publicKey = selectedWalletPublicKeyB58(); + return publicKey.isEmpty() ? String("Wallet not set") : publicKey; } static void releaseTransientUiBuffers() { @@ -1489,6 +1659,21 @@ static void shortVecEncode(size_t value, std::vector &out) { } while (value); } +static bool shortVecDecode(const std::vector &bytes, size_t &offset, uint32_t &valueOut) { + uint32_t value = 0; + uint32_t shift = 0; + while (offset < bytes.size() && shift <= 28) { + uint8_t byte = bytes[offset++]; + value |= (uint32_t)(byte & 0x7F) << shift; + if ((byte & 0x80) == 0) { + valueOut = value; + return true; + } + shift += 7; + } + return false; +} + static void pushU32LE(std::vector &out, uint32_t value) { out.push_back((uint8_t)(value & 0xFF)); out.push_back((uint8_t)((value >> 8) & 0xFF)); @@ -2171,6 +2356,225 @@ static String encodeTransactionBase64(const uint8_t signature[64], const std::ve return bytesToBase64String(tx.data(), tx.size()); } +static bool parseSolanaSignerIndex(const std::vector &txBytes, + const uint8_t targetPub32[32], + uint32_t &signerIndexOut, + size_t &messageOffsetOut, + String &errorOut) { + signerIndexOut = 0; + messageOffsetOut = 0; + size_t offset = 0; + uint32_t signatureCount = 0; + if (!shortVecDecode(txBytes, offset, signatureCount)) { + errorOut = "transaction_signature_count_invalid"; + return false; + } + size_t signatureBytesLen = (size_t)signatureCount * 64; + if (offset + signatureBytesLen + 4 > txBytes.size()) { + errorOut = "transaction_too_short"; + return false; + } + messageOffsetOut = offset + signatureBytesLen; + size_t cursor = messageOffsetOut; + bool versioned = (txBytes[cursor] & 0x80) != 0; + if (versioned) { + cursor += 1; + } + if (cursor + 3 > txBytes.size()) { + errorOut = "transaction_header_invalid"; + return false; + } + uint8_t requiredSignatures = txBytes[cursor]; + cursor += 3; + uint32_t accountCount = 0; + if (!shortVecDecode(txBytes, cursor, accountCount)) { + errorOut = "transaction_accounts_invalid"; + return false; + } + if (accountCount < requiredSignatures || signatureCount < requiredSignatures) { + errorOut = "transaction_signer_count_invalid"; + return false; + } + size_t keysBytesLen = (size_t)accountCount * 32; + if (cursor + keysBytesLen > txBytes.size()) { + errorOut = "transaction_account_keys_truncated"; + return false; + } + for (uint32_t i = 0; i < requiredSignatures; ++i) { + const uint8_t *candidate = txBytes.data() + cursor + ((size_t)i * 32); + if (memcmp(candidate, targetPub32, 32) == 0) { + signerIndexOut = i; + return true; + } + } + errorOut = "wallet_mismatch"; + return false; +} + +static bool signSelectedWalletTransactionBase64(const String &publicKeyBase58, + const String &txBase64, + String &signedTxBase64Out, + String &signatureBase58Out, + String &errorOut) { + signedTxBase64Out = ""; + signatureBase58Out = ""; + errorOut = ""; + + String activePublicKey = selectedWalletPublicKeyB58(); + String activePrivateKey = selectedWalletPrivateKeyB58(); + if (activePublicKey.isEmpty() || activePrivateKey.isEmpty()) { + errorOut = "wallet_unavailable"; + return false; + } + if (activePublicKey != publicKeyBase58) { + errorOut = "wallet_mismatch"; + return false; + } + + std::vector txBytes; + if (!base64DecodeStd(txBase64, txBytes) || txBytes.empty()) { + errorOut = "transaction_base64_invalid"; + return false; + } + + uint8_t expectedPub32[32] = {}; + if (!base58ToFixed32(activePublicKey, expectedPub32)) { + errorOut = "wallet_public_key_invalid"; + return false; + } + + uint8_t seed32[32] = {}; + uint8_t pub32[32] = {}; + uint8_t sec64[64] = {}; + if (!deriveSeedKeypairFromBase58(activePrivateKey, seed32, pub32, sec64)) { + errorOut = "wallet_private_key_invalid"; + return false; + } + if (memcmp(pub32, expectedPub32, 32) != 0) { + errorOut = "wallet_keypair_mismatch"; + return false; + } + + uint32_t signerIndex = 0; + size_t messageOffset = 0; + if (!parseSolanaSignerIndex(txBytes, expectedPub32, signerIndex, messageOffset, errorOut)) { + return false; + } + + std::vector message(txBytes.begin() + (long)messageOffset, txBytes.end()); + uint8_t signature[64] = {}; + if (!signMessageEd25519(message, sec64, signature)) { + errorOut = "transaction_sign_failed"; + return false; + } + + size_t signatureCountOffset = 0; + uint32_t signatureCount = 0; + if (!shortVecDecode(txBytes, signatureCountOffset, signatureCount)) { + errorOut = "transaction_signature_count_invalid"; + return false; + } + size_t signatureOffset = signatureCountOffset + ((size_t)signerIndex * 64); + if (signatureOffset + 64 > txBytes.size()) { + errorOut = "transaction_signature_slot_invalid"; + return false; + } + memcpy(txBytes.data() + signatureOffset, signature, 64); + + signedTxBase64Out = bytesToBase64String(txBytes.data(), txBytes.size()); + signatureBase58Out = bytesToBase58(signature, 64); + return true; +} + +static bool sendWalletRpcResponse(const String &toLogin, + const String &targetSessionId, + const String &callId, + const String &responseData) { + String response; + String req = String("{\"toLogin\":\"") + jsonEscape(toLogin) + + "\",\"targetSessionId\":\"" + jsonEscape(targetSessionId) + + "\",\"callId\":\"" + jsonEscape(callId) + + "\",\"type\":" + String((unsigned int)kWalletRpcSignalTypeResponse) + + ",\"data\":\"" + jsonEscape(responseData) + "\"}"; + return shineWsRequest(gShineWs, "CallSignalToSession", req, response, SHINE_RPC_TIMEOUT_MS); +} + +static void queueWalletSignRequest(const PendingWalletRpcRequest &item, + const String &requestId, + const String &publicKeyBase58, + const String &transactionBase64, + const String &comment) { + if (gActiveWalletSignRequest.active) { + return; + } + gActiveWalletSignRequest.active = true; + gActiveWalletSignRequest.fromLogin = item.fromLogin; + gActiveWalletSignRequest.fromSessionId = item.fromSessionId; + gActiveWalletSignRequest.callId = item.callId; + gActiveWalletSignRequest.requestId = requestId; + gActiveWalletSignRequest.publicKeyBase58 = publicKeyBase58; + gActiveWalletSignRequest.transactionBase64 = transactionBase64; + gActiveWalletSignRequest.comment = comment; + gActiveWalletSignRequest.requestedAtMs = shineNowMs(); + gActiveWalletSignRequest.returnScreen = gCurrentScreen; + gWalletSignStatusMessage = ""; + showScreen(SCREEN_WALLET_SIGN_REQUEST); +} + +static void clearWalletSignRequest() { + gActiveWalletSignRequest = ActiveWalletSignRequest(); + gWalletSignStatusMessage = ""; +} + +static bool respondToActiveWalletSignRequest(bool approved) { + if (!gActiveWalletSignRequest.active) { + return false; + } + + String responseData; + if (!approved) { + responseData = String("{\"v\":1,\"op\":\"sign_transaction_result\",\"requestId\":\"") + + jsonEscape(gActiveWalletSignRequest.requestId) + + "\",\"ok\":false,\"error\":\"rejected_by_user\",\"timeMs\":" + + String((unsigned long long)shineNowMs()) + "}"; + } else { + String signedTxBase64; + String signatureBase58; + String error; + if (!signSelectedWalletTransactionBase64(gActiveWalletSignRequest.publicKeyBase58, + gActiveWalletSignRequest.transactionBase64, + signedTxBase64, + signatureBase58, + error)) { + responseData = String("{\"v\":1,\"op\":\"sign_transaction_result\",\"requestId\":\"") + + jsonEscape(gActiveWalletSignRequest.requestId) + + "\",\"ok\":false,\"error\":\"" + jsonEscape(error.isEmpty() ? String("sign_failed") : error) + + "\",\"timeMs\":" + String((unsigned long long)shineNowMs()) + "}"; + } else { + responseData = String("{\"v\":1,\"op\":\"sign_transaction_result\",\"requestId\":\"") + + jsonEscape(gActiveWalletSignRequest.requestId) + + "\",\"ok\":true,\"publicKeyBase58\":\"" + jsonEscape(gActiveWalletSignRequest.publicKeyBase58) + + "\",\"signatureBase58\":\"" + jsonEscape(signatureBase58) + + "\",\"signedTransactionBase64\":\"" + jsonEscape(signedTxBase64) + + "\",\"timeMs\":" + String((unsigned long long)shineNowMs()) + "}"; + } + } + + bool sent = sendWalletRpcResponse(gActiveWalletSignRequest.fromLogin, + gActiveWalletSignRequest.fromSessionId, + gActiveWalletSignRequest.callId, + responseData); + if (!sent) { + gWalletSignStatusMessage = "Failed to send response"; + return false; + } + + Screen returnScreen = gActiveWalletSignRequest.returnScreen; + clearWalletSignRequest(); + showScreen(returnScreen == SCREEN_WALLET_SIGN_REQUEST ? SCREEN_WALLET : returnScreen); + return true; +} + static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut) { for (int attempt = 0; attempt < 15; attempt++) { String payload; @@ -3397,10 +3801,128 @@ static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const St responseOut = frame; return true; } + queueIncomingWalletRpcRequest(frame); } return false; } +static bool queueIncomingWalletRpcRequest(const String &frame) { + if (frame.indexOf("\"op\":\"IncomingCallSignal\"") < 0 || frame.indexOf("\"event\":true") < 0) { + return false; + } + int payloadPos = frame.indexOf("\"payload\":"); + if (payloadPos < 0) { + return false; + } + int objectStart = frame.indexOf('{', payloadPos); + if (objectStart < 0) { + return false; + } + int objectEnd = -1; + String payloadJson; + if (!extractJsonObjectAt(frame, objectStart, objectEnd, payloadJson)) { + return false; + } + uint64_t type = 0; + String fromLogin; + String fromSessionId; + String callId; + String data; + if (!jsonInt64Field(payloadJson, "type", type) + || !jsonStringField(payloadJson, "fromLogin", fromLogin) + || !jsonStringField(payloadJson, "fromSessionId", fromSessionId) + || !jsonStringField(payloadJson, "callId", callId) + || !jsonStringField(payloadJson, "data", data)) { + return false; + } + if ((int)type != kWalletRpcSignalTypeRequest) { + return false; + } + PendingWalletRpcRequest item; + item.fromLogin = fromLogin; + item.fromSessionId = fromSessionId; + item.callId = callId; + item.type = (int)type; + item.data = data; + gPendingWalletRpcRequests.push_back(item); + return true; +} + +static void processPendingWalletRpcRequests() { + if (gPendingWalletRpcRequests.empty() || !gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) { + return; + } + + while (!gPendingWalletRpcRequests.empty()) { + if (gActiveWalletSignRequest.active) { + return; + } + PendingWalletRpcRequest item = gPendingWalletRpcRequests.front(); + gPendingWalletRpcRequests.erase(gPendingWalletRpcRequests.begin()); + + String operation; + String requestId; + jsonStringField(item.data, "operation", operation); + jsonStringField(item.data, "requestId", requestId); + + String responseData; + if (operation == "get_wallet_public_key") { + String publicKey = selectedWalletPublicKeyB58(); + if (publicKey.isEmpty()) { + responseData = String("{\"v\":1,\"op\":\"get_wallet_public_key_result\",\"requestId\":\"") + + jsonEscape(requestId) + + "\",\"ok\":false,\"error\":\"wallet_unavailable\",\"timeMs\":" + + String((unsigned long long)shineNowMs()) + "}"; + } else { + responseData = String("{\"v\":1,\"op\":\"get_wallet_public_key_result\",\"requestId\":\"") + + jsonEscape(requestId) + + "\",\"ok\":true,\"wallet\":{\"type\":\"" + jsonEscape(selectedWalletTypeCode()) + + "\",\"publicKeyBase58\":\"" + jsonEscape(publicKey) + + "\"},\"timeMs\":" + String((unsigned long long)shineNowMs()) + "}"; + } + } else if (operation == "sign_transaction") { + String publicKeyBase58; + String transactionBase64; + String comment; + jsonStringField(item.data, "publicKeyBase58", publicKeyBase58); + jsonStringField(item.data, "transactionBase64", transactionBase64); + jsonStringField(item.data, "comment", comment); + if (publicKeyBase58.isEmpty() || transactionBase64.isEmpty()) { + responseData = String("{\"v\":1,\"op\":\"sign_transaction_result\",\"requestId\":\"") + + jsonEscape(requestId) + + "\",\"ok\":false,\"error\":\"bad_request\",\"timeMs\":" + + String((unsigned long long)shineNowMs()) + "}"; + } else { + queueWalletSignRequest(item, requestId, publicKeyBase58, transactionBase64, comment); + return; + } + } else { + responseData = String("{\"v\":1,\"op\":\"wallet_rpc_result\",\"requestId\":\"") + + jsonEscape(requestId) + + "\",\"ok\":false,\"error\":\"unsupported_operation\",\"timeMs\":" + + String((unsigned long long)shineNowMs()) + "}"; + } + + sendWalletRpcResponse(item.fromLogin, item.fromSessionId, item.callId, responseData); + } +} + +static void pumpShineIncomingFrames(uint32_t maxFrames) { + if (!gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) { + return; + } + for (uint32_t i = 0; i < maxFrames; ++i) { + if (!gShineWs.client.available()) { + break; + } + String frame; + if (!wsReadTextFrame(gShineWs, frame, 5)) { + break; + } + queueIncomingWalletRpcRequest(frame); + } +} + static bool refreshPairingRequests(String &errorOut) { errorOut = ""; String authError; @@ -3783,7 +4305,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { + ",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) + "\",\"sessionType\":" + String((unsigned int)kSessionTypeHomeserver) + ",\"clientPlatform\":\"" + jsonEscape(kSessionClientPlatformEsp32) - + "\",\"clientInfo\":\"ESP32 homeserver\"}"; + + "\",\"clientInfo\":\"" + jsonEscape(String("ESP32 homeserver:") + gHomeserverValue) + "\"}"; String loginResp; if (shineWsRequest(gShineWs, "SessionLogin", loginReq, loginResp)) { diagDetails += "session_login_response<<\n"; @@ -3850,7 +4372,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { + "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) + "\",\"sessionType\":" + String((unsigned int)kSessionTypeHomeserver) + ",\"clientPlatform\":\"" + jsonEscape(kSessionClientPlatformEsp32) - + "\",\"clientInfo\":\"ESP32 homeserver\"}"; + + "\",\"clientInfo\":\"" + jsonEscape(String("ESP32 homeserver:") + gHomeserverValue) + "\"}"; String createResp; if (!shineWsRequest(gShineWs, "CreateAuthSession", createReq, createResp)) { diagDetails += "create_auth_session_error=request_failed\n"; @@ -3938,6 +4460,9 @@ static void manageShineConnection() { clearShineSessionState(false); gShineReconnectDelayMs = min(gShineReconnectDelayMs + SHINE_RECONNECT_MIN_MS, (unsigned long)SHINE_RECONNECT_MAX_MS); } + + pumpShineIncomingFrames(); + processPendingWalletRpcRequests(); } static void upsertKnownWifi(const String &ssid, const String &password) { @@ -3985,6 +4510,15 @@ static void loadPrefs() { gShineServerUrl = gPrefs.getString("shine_server", "https://shineup.me"); gLoginValue = gPrefs.getString("login", ""); gHomeserverValue = gPrefs.getString("homeserver", "homeserver1"); + String walletTypeStored = gPrefs.getString("wallet_type", "dev.key"); + if (walletTypeStored == "root.key") { + gSelectedWalletType = WALLET_SELECTION_ROOT; + } else if (walletTypeStored == "custom") { + gSelectedWalletType = WALLET_SELECTION_CUSTOM; + } else { + gSelectedWalletType = WALLET_SELECTION_DEVICE; + } + gCustomWalletName = gPrefs.getString("wallet_custom_name", ""); gSecretConfigured = gPrefs.getBool("secret_set", false); gSecretBase58 = gPrefs.getString("secret_b58", ""); if (gSecretConfigured && gPrefs.getBytesLength("secret_bytes") == 32) { @@ -4005,9 +4539,7 @@ static void loadPrefs() { gLastRegisterDiagSummary = gPrefs.getString("reg_diag_summary", ""); loadRegisterDiagDetailsFromPrefs(); gLastRegisterDiagTime = gPrefs.getString("reg_diag_time", ""); - gBalanceStatusMessage = gDevicePubB58.isEmpty() ? "Balance: secret not set" : "Balance: tap to load"; - gBalanceAutoRefreshPending = !gDevicePubB58.isEmpty(); - gLastBalanceAutoRefreshAttemptMs = 0; + refreshSelectedWalletBalanceState(); gAccountCheckPending = true; gLastAccountCheckMs = 0; gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; @@ -4050,6 +4582,8 @@ static void saveServerPrefs() { static void saveAccountPrefs() { gPrefs.putString("login", gLoginValue); gPrefs.putString("homeserver", gHomeserverValue); + gPrefs.putString("wallet_type", selectedWalletTypeCode()); + gPrefs.putString("wallet_custom_name", gCustomWalletName); gPrefs.putBool("secret_set", gSecretConfigured); gPrefs.putString("secret_b58", gSecretBase58); if (gUserPdaAddress.isEmpty()) { @@ -4387,9 +4921,7 @@ static void clearSecretValue() { gSecretBase58 = ""; memset(gSecretBytes, 0, sizeof(gSecretBytes)); refreshDerivedKeys(); - gBalanceStatusMessage = "Balance: secret not set"; - gBalanceAutoRefreshPending = false; - gLastBalanceAutoRefreshAttemptMs = 0; + refreshSelectedWalletBalanceState(); saveAccountPrefs(); markAccountStateDirty(); } @@ -4401,9 +4933,7 @@ static void setSecretValue(const uint8_t *bytes32) { gSecretBase58 = b58; gSecretConfigured = true; refreshDerivedKeys(); - gBalanceStatusMessage = "Balance: tap to load"; - gBalanceAutoRefreshPending = true; - gLastBalanceAutoRefreshAttemptMs = 0; + refreshSelectedWalletBalanceState(); saveAccountPrefs(); markAccountStateDirty(); } @@ -4601,6 +5131,17 @@ static void applyEditorValue() { return; } + if (gEditContext == EDIT_CONTEXT_WALLET_CUSTOM) { + value.trim(); + gCustomWalletName = value; + refreshDerivedKeys(); + gSelectedWalletType = WALLET_SELECTION_CUSTOM; + refreshSelectedWalletBalanceState(); + saveAccountPrefs(); + showScreen(SCREEN_WALLET); + return; + } + if (gEditContext == EDIT_CONTEXT_SECRET_MANUAL) { value.trim(); uint8_t decoded[64] = {}; @@ -4781,9 +5322,57 @@ static void actionButtonCb(lv_event_t *event) { case ACTION_OPEN_SETTINGS: showScreen(SCREEN_SETTINGS_MENU); break; + case ACTION_OPEN_WALLET: + showScreen(SCREEN_WALLET); + break; + case ACTION_WALLET_SELECT: + showScreen(SCREEN_WALLET_SELECT); + break; + case ACTION_WALLET_SELECT_DEVICE: + gSelectedWalletType = WALLET_SELECTION_DEVICE; + refreshSelectedWalletBalanceState(); + saveAccountPrefs(); + showScreen(SCREEN_WALLET); + break; + case ACTION_WALLET_SELECT_ROOT: + gSelectedWalletType = WALLET_SELECTION_ROOT; + refreshSelectedWalletBalanceState(); + saveAccountPrefs(); + showScreen(SCREEN_WALLET); + break; + case ACTION_WALLET_SELECT_CUSTOM: { + String customName = gCustomWalletName; + customName.trim(); + if (customName.isEmpty() || gSelectedWalletType == WALLET_SELECTION_CUSTOM) { + openEditor(EDIT_CONTEXT_WALLET_CUSTOM, + SCREEN_WALLET_SELECT, + "CUSTOM WALLET", + "", + gCustomWalletName, + false); + } else { + gSelectedWalletType = WALLET_SELECTION_CUSTOM; + refreshSelectedWalletBalanceState(); + saveAccountPrefs(); + showScreen(SCREEN_WALLET); + } + break; + } case ACTION_OPEN_WALLET_QR: showScreen(SCREEN_WALLET_QR); break; + case ACTION_WALLET_SIGN_APPROVE: + if (!respondToActiveWalletSignRequest(true)) { + gWalletSignStatusMessage = "Approve failed"; + rebuildScreen(); + } + break; + case ACTION_WALLET_SIGN_REJECT: + if (!respondToActiveWalletSignRequest(false)) { + gWalletSignStatusMessage = "Reject failed"; + rebuildScreen(); + } + break; case ACTION_OPEN_PAIRING_REQUESTS: { String error; if (!refreshPairingRequests(error)) { @@ -4940,11 +5529,11 @@ static void actionButtonCb(lv_event_t *event) { break; case ACTION_REFRESH_BALANCE: { String message; - gBalanceStatusMessage = "Balance: loading..."; + gBalanceStatusMessage = "Wallet: loading balance..."; rebuildScreen(); lv_timer_handler(); refreshWalletBalance(message); - if (gCurrentScreen == SCREEN_HOME) { + if (gCurrentScreen == SCREEN_HOME || gCurrentScreen == SCREEN_WALLET) { rebuildScreen(); } break; @@ -5160,8 +5749,8 @@ static void drawHome() { makeTitle("STATUS", 112, &lv_font_montserrat_28); makeRichStatusLine(wifiHomeRichLine(), 24, 164, 432, &lv_font_montserrat_18); makeRichStatusLine(shineHomeRichLine(), 24, 202, 432, &lv_font_montserrat_16); - makeButtonLeftText(balanceHomeLine().c_str(), 22, 254, 340, 56, 0x355C7D, ACTION_REFRESH_BALANCE, &lv_font_montserrat_18); - makeButton("QR", 374, 254, 84, 56, 0x2A6F97, ACTION_OPEN_WALLET_QR, &lv_font_montserrat_20); + String walletButtonLabel = String("Wallet: ") + selectedWalletDisplayName(); + makeButtonLeftText(walletButtonLabel.c_str(), 22, 254, 436, 56, 0x355C7D, ACTION_OPEN_WALLET, &lv_font_montserrat_18); if (gShowRegisterAccountButton) { makeButton("REGISTER ACCOUNT", 20, 360, 210, 78, 0x2A9D8F, ACTION_REGISTER_ACCOUNT, &lv_font_montserrat_20); } else if (gShowHomeserverPdaActionButton) { @@ -5174,6 +5763,118 @@ static void drawHome() { makeVersionTag(); } +static void drawWalletScreen() { + setRootStyle(); + makeTitle("WALLET", 22, &lv_font_montserrat_24); + String walletLabel = String("Wallet: ") + selectedWalletDisplayName(); + String walletAddress = selectedWalletPublicKeyB58(); + makeButtonLeftText(walletLabel.c_str(), 22, 88, 436, 64, 0x355C7D, ACTION_WALLET_SELECT, &lv_font_montserrat_20); + makeBody(gBalanceStatusMessage.c_str(), 168, 420); + if (!walletAddress.isEmpty()) { + String keyLine = String("PubKey: ") + abbreviateValue(walletAddress, 16, 12); + makeBody(keyLine.c_str(), 206, 420); + } else { + makeBody("Wallet is not available yet", 206, 420); + } + makeButton("SHOW BALANCE", 22, 264, 436, 62, 0x2A9D8F, ACTION_REFRESH_BALANCE, &lv_font_montserrat_18); + makeButton("SHOW WALLET QR", 22, 338, 436, 62, 0x2A6F97, ACTION_OPEN_WALLET_QR, &lv_font_montserrat_18); + makeVersionTag(); +} + +static void drawWalletSelectScreen() { + setRootStyle(); + makeTitle("SELECT WALLET", 22, &lv_font_montserrat_24); + String currentLine = String("Current: ") + selectedWalletDisplayName(); + makeBody(currentLine.c_str(), 88, 420); + String deviceLabel = String(gSelectedWalletType == WALLET_SELECTION_DEVICE ? "✓ DeviceKey" : "DeviceKey"); + String rootLabel = String(gSelectedWalletType == WALLET_SELECTION_ROOT ? "✓ RootKey" : "RootKey"); + String customBase = gCustomWalletName; + customBase.trim(); + if (customBase.isEmpty()) { + customBase = "Custom"; + } else { + customBase = String("Custom: ") + customBase; + } + String customLabel = gSelectedWalletType == WALLET_SELECTION_CUSTOM ? String("✓ ") + customBase : customBase; + makeButton(deviceLabel.c_str(), 22, 146, 436, 64, 0x2A6F97, ACTION_WALLET_SELECT_DEVICE, &lv_font_montserrat_20); + makeButton(rootLabel.c_str(), 22, 224, 436, 64, 0x355C7D, ACTION_WALLET_SELECT_ROOT, &lv_font_montserrat_20); + makeButton(customLabel.c_str(), 22, 302, 436, 64, 0x7A5C9B, ACTION_WALLET_SELECT_CUSTOM, &lv_font_montserrat_18); + makeBody("Tap Custom again to change its name", 386, 420); + makeVersionTag(); +} + +static void drawWalletSignRequestScreen() { + setRootStyle(); + makeTitle("SIGN REQUEST", 18, &lv_font_montserrat_24); + + if (!gActiveWalletSignRequest.active) { + showMessageAt("No active sign request", 96); + makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_OPEN_WALLET, &lv_font_montserrat_22); + makeVersionTag(); + return; + } + + lv_obj_t *panel = lv_obj_create(gRoot); + lv_obj_set_size(panel, 440, 244); + lv_obj_set_pos(panel, 20, 82); + lv_obj_set_style_bg_color(panel, lv_color_hex(0x0F1B27), 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0); + lv_obj_set_style_radius(panel, 14, 0); + lv_obj_set_style_pad_all(panel, 16, 0); + lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + + String question = String("Sign transaction with ") + selectedWalletDisplayName() + "?"; + String comment = gActiveWalletSignRequest.comment; + comment.trim(); + if (comment.isEmpty()) { + comment = "No comment provided"; + } + String walletLine = String("Wallet: ") + abbreviateValue(gActiveWalletSignRequest.publicKeyBase58, 16, 12); + String sourceLine = String("From: ") + abbreviateValue(gActiveWalletSignRequest.fromLogin, 14, 8); + + lv_obj_t *qLabel = lv_label_create(panel); + lv_label_set_text(qLabel, question.c_str()); + lv_obj_set_width(qLabel, 404); + lv_label_set_long_mode(qLabel, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(qLabel, &lv_font_montserrat_22, 0); + lv_obj_set_style_text_color(qLabel, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_pos(qLabel, 0, 0); + + lv_obj_t *walletLabel = lv_label_create(panel); + lv_label_set_text(walletLabel, walletLine.c_str()); + lv_obj_set_width(walletLabel, 404); + lv_label_set_long_mode(walletLabel, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(walletLabel, &lv_font_montserrat_18, 0); + lv_obj_set_style_text_color(walletLabel, lv_color_hex(0xBED5F5), 0); + lv_obj_set_pos(walletLabel, 0, 52); + + lv_obj_t *sourceLabel = lv_label_create(panel); + lv_label_set_text(sourceLabel, sourceLine.c_str()); + lv_obj_set_width(sourceLabel, 404); + lv_label_set_long_mode(sourceLabel, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(sourceLabel, &lv_font_montserrat_18, 0); + lv_obj_set_style_text_color(sourceLabel, lv_color_hex(0xA8D6A2), 0); + lv_obj_set_pos(sourceLabel, 0, 82); + + lv_obj_t *commentLabel = lv_label_create(panel); + lv_label_set_text(commentLabel, comment.c_str()); + lv_obj_set_width(commentLabel, 404); + lv_label_set_long_mode(commentLabel, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(commentLabel, &lv_font_montserrat_18, 0); + lv_obj_set_style_text_color(commentLabel, lv_color_hex(0xD5DEE7), 0); + lv_obj_set_pos(commentLabel, 0, 118); + + if (!gWalletSignStatusMessage.isEmpty()) { + showMessageAt(gWalletSignStatusMessage, 336); + } + + makeButton("REJECT", 20, 378, 208, 72, 0x6A2430, ACTION_WALLET_SIGN_REJECT, &lv_font_montserrat_22); + makeButton("APPROVE", 252, 378, 208, 72, 0x2A9D8F, ACTION_WALLET_SIGN_APPROVE, &lv_font_montserrat_22); + makeVersionTag(); +} + static void drawWalletQrScreen() { setRootStyle(); gWalletQrTapReturnPending = false; @@ -5874,9 +6575,18 @@ static void rebuildScreen() { case SCREEN_HOME: drawHome(); break; + case SCREEN_WALLET: + drawWalletScreen(); + break; + case SCREEN_WALLET_SELECT: + drawWalletSelectScreen(); + break; case SCREEN_WALLET_QR: drawWalletQrScreen(); break; + case SCREEN_WALLET_SIGN_REQUEST: + drawWalletSignRequestScreen(); + break; case SCREEN_SETTINGS_MENU: drawSettingsMenu(); break; @@ -5942,6 +6652,15 @@ static void handleHomeSwipe(SwipeDirection swipe) { } } +static void handleWalletSwipe(SwipeDirection swipe) { + if (gCurrentScreen == SCREEN_WALLET_SIGN_REQUEST && gActiveWalletSignRequest.active) { + return; + } + if (swipe == SWIPE_RIGHT) { + showScreen(SCREEN_HOME); + } +} + static void handleSettingsSwipe(SwipeDirection swipe) { if (swipe == SWIPE_RIGHT) { showScreen(SCREEN_HOME); @@ -6039,8 +6758,11 @@ static void handleSwipe(SwipeDirection swipe) { case SCREEN_HOME: handleHomeSwipe(swipe); break; + case SCREEN_WALLET: + case SCREEN_WALLET_SELECT: case SCREEN_WALLET_QR: - handleHomeSwipe(swipe); + case SCREEN_WALLET_SIGN_REQUEST: + handleWalletSwipe(swipe); break; case SCREEN_SETTINGS_MENU: handleSettingsSwipe(swipe); @@ -6144,7 +6866,7 @@ void loop() { lv_timer_handler(); if (gWalletQrTapReturnPending) { gWalletQrTapReturnPending = false; - showScreen(SCREEN_HOME); + showScreen(SCREEN_WALLET); } manageWifiReconnect(); manageAccountPdaRefresh(); diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index f61eed3..eee3b39 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -19,7 +19,7 @@ - локальный UI на тач-экране; - хранение настроек и секретов во внутренней памяти `ESP32` через `NVS`; -- русский текст в логике интерфейса; в текущей временной сборке отображение на экране идёт через ASCII-транслитерацию, потому что `U8g2`-шрифты на устройстве временно не рисуются; +- экранный UI устройства работает на английском языке, потому что кириллица на этом шрифтовом пути пока не поддерживается стабильно; - экран пополнения с реальным `solana:` URI и рисованием QR-кода через `LVGL`; - реальное подключение к `Wi-Fi` по сохранённым `SSID/паролю`; - реальная проверка доступности `API`, `RPC` и `WS`-адресов; @@ -77,7 +77,7 @@ - `sessionType = 100` - `clientPlatform = "ESP32"` -- `clientInfo = "ESP32 homeserver"` +- `clientInfo = "ESP32 homeserver:"` Это относится и к `CreateAuthSession`, и к `SessionLogin`. @@ -107,14 +107,15 @@ 7. `ACCOUNT` 8. `WALLET` 9. `WALLET_QR` -10. `REQUESTS` -11. `REQUEST_DETAIL` -12. `SETTINGS` -13. `PIN_EDIT` -14. `TEXT_INPUT` -15. `CONFIRM` -16. `REGISTER_ACCOUNT_CONFIRM` -17. `REGISTER_ACCOUNT_RESULT` +10. `WALLET_SIGN_REQUEST` +11. `REQUESTS` +12. `REQUEST_DETAIL` +13. `SETTINGS` +14. `PIN_EDIT` +15. `TEXT_INPUT` +16. `CONFIRM` +17. `REGISTER_ACCOUNT_CONFIRM` +18. `REGISTER_ACCOUNT_RESULT` ## Общие правила интерфейса @@ -125,9 +126,9 @@ - затем, если устройство реально заряжается, маленькая иконка молнии; - затем контур батареи; - затем индикатор `Wi-Fi`. -- Основной язык прототипа: русский. +- Основной язык прототипа: английский. - Для вывода текста в текущей временной сборке используется стандартный шрифт `Arduino_GFX`. -- Русские строки на экране временно показываются в ASCII-транслитерации. +- Русские строки в UI устройства пока не использовать. - Кнопки крупные, с тач-ориентированным размером. - Опасные действия подтверждаются отдельным диалогом. - После изменения данных конфигурация сразу сохраняется в `NVS`. @@ -172,20 +173,23 @@ - блок `STATUS` поднят выше относительно предыдущей версии; - если состояние хорошее, слово `connected` в строках `Wi-Fi` и `SHiNE` показывается зелёным. -В зоне баланса: +В зоне кошелька: -- основная кнопка показа/обновления баланса занимает примерно 80% строки; -- текст на кнопке баланса выровнен левее центра; -- справа от неё стоит отдельная кнопка `QR`; -- после старта устройства баланс пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`; -- нажатие на кнопку `QR` открывает экран `WALLET_QR`. +- вместо старых двух кнопок `баланс` и `QR` показывается одна широкая кнопка; +- текст кнопки: `Wallet: `; +- доступные имена: + - `DeviceKey` + - `RootKey` + - либо сохранённое имя `custom`-кошелька; +- после старта устройства баланс активного выбранного кошелька пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`; +- нажатие на эту кнопку открывает экран `WALLET`. Нижние кнопки: - `Статус` - `Подключение` - `Аккаунт` -- `Кошелёк` +- `Wallet` - `Запросы` - `Настройки` @@ -410,26 +414,43 @@ Показывает: -- адрес кошелька устройства; -- баланс в `SOL`; -- минимально рекомендуемую сумму для регистрации; -- статус `Хватает / Не хватает`. +- кнопку `Wallet: `; +- строку текущего статуса/баланса активного кошелька; +- сокращённый публичный ключ активного кошелька. Кнопки: -- `QR и URI` -- `+0.10 SOL` -- `+0.25 SOL` -- `-0.10 SOL` -- `Проверить` -- `Назад` +- `Wallet: ` +- `SHOW BALANCE` +- `SHOW WALLET QR` Поведение: -- кнопки пополнения/уменьшения нужны для теста сценариев; -- `Проверить` читает реальный баланс из `Solana RPC`; -- адрес кошелька должен совпадать с `device key`, вычисленным из сохранённого `master secret`; -- отрицательный баланс не допускается. +- верхняя кнопка открывает экран выбора кошелька `WALLET_SELECT`; +- `Показать баланс кошелька` читает реальный баланс именно активного выбранного кошелька из `Solana RPC`; +- `Показать QR-код кошелька` открывает экран `WALLET_QR`; +- этот экран не меняет логику Solana-регистрации пользователя: on-chain регистрация и проверки `PDA` по-прежнему завязаны на `device key`. + +## Экран WALLET_SELECT + +Показывает: + +- строку `Current: `; +- три кнопки выбора: + - `DeviceKey` + - `RootKey` + - `Custom` или `Custom: <имя>`; +- у текущего выбора видна галочка. + +Поведение: + +- `DeviceKey` активирует кошелёк, выведенный из suffix `dev.key`; +- `RootKey` активирует кошелёк, выведенный из suffix `root.key`; +- `Custom` использует derivation: + `sha256(base64(secret32) + "|wallet." + customName)`; +- если имя `custom`-кошелька ещё не задано, нажатие `Custom` открывает экран текстового ввода; +- если `custom` уже выбран, повторное нажатие на него открывает экран редактирования имени; +- после выбора кошелька устройство возвращается на экран `WALLET`. ## Экран WALLET_QR @@ -452,8 +473,29 @@ Поведение: - QR должен быть сканируемым, а не декоративным; -- адрес кошелька берётся из `device key`, вычисленного из сохранённого `master secret`; -- нажатие в любую точку экрана возвращает пользователя на `HOME`. +- адрес кошелька берётся из текущего активного выбранного кошелька; +- нажатие в любую точку экрана возвращает пользователя на `WALLET`. + +## Экран WALLET_SIGN_REQUEST + +Экран показывается, когда браузерное wallet-расширение присылает на ESP32 запрос `sign_transaction`. + +Показывает: + +- заголовок `SIGN REQUEST`; +- вопрос о подписи транзакции текущим активным кошельком; +- сокращённый публичный ключ кошелька; +- комментарий, присланный полем `comment`; +- две кнопки: + - `REJECT` + - `APPROVE` + +Поведение: + +- если пользователь нажимает `APPROVE`, ESP32 подписывает транзакцию текущим активным кошельком и отправляет ответ в расширение; +- если пользователь нажимает `REJECT`, ESP32 отправляет отказ `rejected_by_user`; +- пока запрос активен, свайпом этот экран не закрывается; +- после ответа расширению устройство возвращается на предыдущий экран. ## Экран REQUESTS @@ -616,8 +658,8 @@ В текущей диагностической версии: -- строковые литералы в коде остаются русскими и в `UTF-8`; -- перед выводом на экран они временно транслитерируются в ASCII; +- строковые литералы экранного UI должны оставаться английскими ASCII-совместимыми; +- возвращение кириллицы допустимо только после отдельной доработки шрифтов и реальной проверки на устройстве; - рендер выполняется стандартным шрифтом `Arduino_GFX`; - это обходной режим, пока `U8g2`-шрифты на устройстве не начнут рисоваться стабильно. @@ -626,7 +668,7 @@ Минимально нужно проверить: 1. устройство загружается и сразу открывает `HOME`; экран блокировки временно отключён; -2. текст отображается читаемо хотя бы в ASCII-транслитерации; +2. весь экранный текст отображается читаемо на английском без замены символов; 3. ввод по экранной клавиатуре работает; 4. после перезагрузки сохранённые поля остаются в памяти; 5. секрет и адрес кошелька сохраняются на устройстве; diff --git a/SHiNE-browser-plugin-wallet/README.md b/SHiNE-browser-plugin-wallet/README.md index 312a042..1f3fecf 100644 --- a/SHiNE-browser-plugin-wallet/README.md +++ b/SHiNE-browser-plugin-wallet/README.md @@ -10,7 +10,7 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login. - принять `session-only` payload без передачи `deviceKey/rootKey/blockchainKey`; - сохранить `sessionPriv/sessionKey/sessionId` в локальном хранилище plugin; - восстанавливать session через `SessionChallenge -> SessionLogin`; -- держать wallet-state в `background service worker`, а popup использовать как UI. +- держать wallet-state в `background service worker`, а side panel использовать как UI. - принимать не адрес сервера, а логин серверного аккаунта SHiNE и находить точный `https://...` / `wss://...` адрес через его PDA. ## Как загрузить локально @@ -19,13 +19,15 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login. 2. Включи `Developer mode` 3. Нажми `Load unpacked` 4. Выбери папку `SHiNE-browser-plugin-wallet/` +5. Нажми на иконку расширения в toolbar: откроется side panel SHiNE Wallet ## Ограничения текущего этапа -- plugin пока не держит постоянный фоновый WS-канал после закрытия popup, но хранит wallet-state в `background`; +- plugin пока не держит постоянный фоновый WS-канал без активного действия пользователя, но хранит wallet-state в `background`; - на этом этапе реализован только `session-only login`; - запросы на подпись будут следующим этапом. - pairing-пароль, если он используется, должен генерироваться в формате `sha256$` от строки `shine-pairing|loginLower|password`. +- сторона side panel в Chromium выбирается самим браузером/пользователем; extension не закрепляет панель принудительно слева. ## Сборка crypto bundle diff --git a/SHiNE-browser-plugin-wallet/background.js b/SHiNE-browser-plugin-wallet/background.js index c90a59a..18e6d16 100644 --- a/SHiNE-browser-plugin-wallet/background.js +++ b/SHiNE-browser-plugin-wallet/background.js @@ -26,14 +26,44 @@ const state = { connectionOnline: false, walletProfile: null, signing: { - selectedKeyId: 'device', selectedDeviceName: '', devicesResolvedAtMs: 0, }, + currentWallet: null, statusText: '', statusKind: 'info', }; +const WALLET_RPC_REQUEST_TYPE = 9100; +const WALLET_RPC_RESPONSE_TYPE = 9101; + +async function configureSidePanelBehavior() { + if (!chrome.sidePanel?.setPanelBehavior) { + return; + } + try { + await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); + } catch (error) { + console.warn('Failed to configure SHiNE side panel behavior:', error); + } +} + +function normalizeOrigin(value = '') { + const raw = String(value || '').trim(); + if (!raw) return ''; + try { + return new URL(raw).origin; + } catch { + return raw; + } +} + +function makeCodeError(message, code) { + const error = new Error(String(message || 'Wallet error')); + error.code = String(code || '').trim().toUpperCase(); + return error; +} + function setStatus(message = '', kind = 'info') { state.statusText = String(message || ''); state.statusKind = kind === 'error' ? 'error' : 'info'; @@ -71,14 +101,15 @@ async function loadStateFromStorage() { serverHttp: String(settings?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim() || buildHttpBase('shineup.me'), serverUrl: String(settings?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws', login: String(settings?.login || '').trim(), + connectedOrigins: Array.isArray(settings?.connectedOrigins) ? settings.connectedOrigins.map((item) => normalizeOrigin(item)).filter(Boolean) : [], }; state.activeSession = await loadSessionMaterial(); state.walletProfile = state.activeSession?.walletProfile || null; state.signing = { - selectedKeyId: String(state.activeSession?.selectedKeyId || 'device'), selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''), devicesResolvedAtMs: Number(state.activeSession?.devicesResolvedAtMs || 0), }; + state.currentWallet = state.activeSession?.currentWallet || null; } async function persistSettings(nextSettings = {}) { @@ -86,10 +117,29 @@ async function persistSettings(nextSettings = {}) { ...state.settings, ...nextSettings, }; + if (!Array.isArray(state.settings.connectedOrigins)) { + state.settings.connectedOrigins = []; + } await savePluginSettings(state.settings); return state.settings; } +function isOriginApproved(origin) { + const normalized = normalizeOrigin(origin); + return !!normalized && Array.isArray(state.settings.connectedOrigins) && state.settings.connectedOrigins.includes(normalized); +} + +async function setOriginApproved(origin, approved) { + const normalized = normalizeOrigin(origin); + const current = new Set(Array.isArray(state.settings.connectedOrigins) ? state.settings.connectedOrigins : []); + if (approved) { + if (normalized) current.add(normalized); + } else if (normalized) { + current.delete(normalized); + } + await persistSettings({ connectedOrigins: [...current] }); +} + async function resolveServerForLogin(login) { const cleanLogin = String(login || state.settings.login || '').trim(); if (!cleanLogin) { @@ -119,9 +169,9 @@ async function saveActiveSessionRecord() { const nextRecord = { ...state.activeSession, walletProfile: state.walletProfile, - selectedKeyId: state.signing.selectedKeyId, selectedDeviceName: state.signing.selectedDeviceName, devicesResolvedAtMs: state.signing.devicesResolvedAtMs, + currentWallet: state.currentWallet, }; state.activeSession = nextRecord; await saveSessionMaterial(nextRecord); @@ -152,27 +202,10 @@ function toWalletErrorMessage(error, fallback = 'Не удалось выпол return raw || fallback; } -function buildSigningKeyOptions(walletProfile) { - const rootKey = String(walletProfile?.publicKeys?.rootKeyBase58 || '').trim(); - const deviceKey = String(walletProfile?.publicKeys?.deviceKeyBase58 || '').trim(); - const options = []; - if (rootKey) { - options.push({ - id: 'root', - label: `rootKey (ed25519, ${shortKey(rootKey)})`, - keyType: 'ed25519', - publicKeyBase58: rootKey, - }); - } - if (deviceKey) { - options.push({ - id: 'device', - label: `deviceKey (ed25519, ${shortKey(deviceKey)})`, - keyType: 'ed25519', - publicKeyBase58: deviceKey, - }); - } - return options; +function homeserverSessionNameFromClientInfo(value = '') { + const raw = String(value || '').trim(); + const match = raw.match(/^ESP32 homeserver:(.+)$/i); + return match ? String(match[1] || '').trim() : ''; } function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) { @@ -181,18 +214,25 @@ function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) ? serverSessions.filter((item) => Number(item?.sessionType || 0) === 100) : []; const onlineHomeservers = homeserverSessions.filter((item) => !!item?.onlineOnThisServer); + const byName = new Map(); + onlineHomeservers.forEach((item) => { + const sessionName = homeserverSessionNameFromClientInfo(item?.clientInfoFromClient); + if (sessionName) { + byName.set(sessionName, item); + } + }); return published.map((item) => { - let onlineState = 'unknown'; - if (published.length === 1) { - onlineState = onlineHomeservers.length > 0 ? 'online' : 'offline'; - } else if (onlineHomeservers.length === 0) { - onlineState = 'offline'; - } else if (onlineHomeservers.length === published.length) { + const matched = byName.get(String(item?.sessionName || '').trim()) || null; + let onlineState = matched ? 'online' : 'offline'; + let activeSessionId = matched?.sessionId ? String(matched.sessionId) : ''; + if (!matched && published.length === 1 && onlineHomeservers.length === 1) { onlineState = 'online'; + activeSessionId = String(onlineHomeservers[0]?.sessionId || ''); } return { ...item, + activeSessionId, onlineState, onlineLabel: onlineState === 'online' ? 'online' : onlineState === 'offline' ? 'offline' : 'unknown', }; @@ -203,25 +243,20 @@ async function hydrateWalletProfile(login) { const cleanLogin = String(login || state.activeSession?.login || state.settings.login || '').trim(); if (!cleanLogin) throw new Error('Нет логина для чтения PDA кошелька.'); const profile = await readWalletProfileByLogin(cleanLogin); - const signingKeyOptions = buildSigningKeyOptions(profile); - const selectedKeyId = signingKeyOptions.some((item) => item.id === state.signing.selectedKeyId) - ? state.signing.selectedKeyId - : (signingKeyOptions[0]?.id || ''); const selectedDeviceName = state.signing.selectedDeviceName || String(profile?.homeserverSessions?.[0]?.sessionName || ''); state.walletProfile = { ...profile, - signingKeyOptions, homeserverSessions: Array.isArray(profile.homeserverSessions) ? profile.homeserverSessions.map((item) => ({ ...item, onlineState: 'unknown', onlineLabel: 'unknown', + activeSessionId: '', })) : [], }; state.signing = { ...state.signing, - selectedKeyId, selectedDeviceName, }; await saveActiveSessionRecord(); @@ -293,6 +328,7 @@ async function attachApprovedSession(payload) { serverUrl: sessionRecord.serverUrl, }); state.connectionOnline = false; + state.currentWallet = null; } async function pollPairingStatus() { @@ -400,10 +436,10 @@ async function disconnectSession() { state.connectionOnline = false; state.walletProfile = null; state.signing = { - selectedKeyId: 'device', selectedDeviceName: '', devicesResolvedAtMs: 0, }; + state.currentWallet = null; setStatus('Сохранённая wallet-session удалена из plugin.', 'info'); return { ok: true }; } @@ -440,37 +476,199 @@ async function refreshWalletDevices() { } } -async function updateSigningSelection({ selectedKeyId, selectedDeviceName } = {}) { +async function updateSigningSelection({ selectedDeviceName } = {}) { state.signing = { ...state.signing, - selectedKeyId: String(selectedKeyId || state.signing.selectedKeyId || ''), selectedDeviceName: String(selectedDeviceName || state.signing.selectedDeviceName || ''), }; await saveActiveSessionRecord(); return { ok: true }; } -async function prepareSignSignal() { +async function resolveSelectedHomeserverSession() { if (!state.activeSession?.login) { throw new Error('Сначала подключите wallet-session.'); } - if (!state.signing.selectedKeyId) { - throw new Error('Не выбран ключ подписи.'); - } if (!state.signing.selectedDeviceName) { throw new Error('Не выбрано устройство homeserver.'); } - const selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName); + let selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName); if (!selectedDevice) { throw new Error('Выбранное устройство не найдено в PDA аккаунта.'); } - setStatus( - `Каркас готов: запрос подписи должен идти через ${selectedDevice.sessionName}. Сам signaling подписи ещё не доделан.`, - 'info', - ); + if (!selectedDevice.activeSessionId) { + await refreshWalletDevices(); + selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName); + } + if (!selectedDevice?.activeSessionId) { + throw new Error('Выбранный homeserver сейчас не найден онлайн на сервере SHiNE.'); + } + return selectedDevice; +} + +async function callWalletRpc(requestData, timeoutMs = 8000) { + const selectedDevice = await resolveSelectedHomeserverSession(); + const resumed = await resumeActiveSession({ keepConnected: true }); + if (!resumed.ok) { + throw new Error(resumed.error || 'Не удалось открыть wallet-session.'); + } + + const requestId = String(requestData?.requestId || `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`); + const callId = `wallet-rpc-${requestId}`; + const payload = { + ...requestData, + requestId, + timeMs: Number(requestData?.timeMs || Date.now()), + }; + + try { + const response = await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + off(); + reject(new Error('Таймаут ответа от ESP32.')); + }, timeoutMs); + const off = ensureApi().onEvent('IncomingCallSignal', (evt) => { + const eventPayload = evt?.payload || {}; + if (String(eventPayload?.callId || '') !== callId) return; + if (Number(eventPayload?.type || 0) !== WALLET_RPC_RESPONSE_TYPE) return; + clearTimeout(timeoutId); + off(); + try { + resolve(JSON.parse(String(eventPayload?.data || '{}'))); + } catch { + reject(new Error('ESP32 вернул некорректный JSON.')); + } + }); + ensureApi().callSignalToSession({ + toLogin: state.activeSession.login, + targetSessionId: selectedDevice.activeSessionId, + callId, + type: WALLET_RPC_REQUEST_TYPE, + data: JSON.stringify(payload), + }).catch((error) => { + clearTimeout(timeoutId); + off(); + reject(error); + }); + }); + return { response, selectedDevice, requestId }; + } finally { + ensureApi().close(); + state.api = null; + state.connectionOnline = false; + } +} + +function verifyWalletAgainstPda(wallet) { + const type = String(wallet?.type || '').trim(); + const pub = String(wallet?.publicKeyBase58 || '').trim(); + const rootKey = String(state.walletProfile?.publicKeys?.rootKeyBase58 || '').trim(); + const deviceKey = String(state.walletProfile?.publicKeys?.deviceKeyBase58 || '').trim(); + if (type === 'dev.key') { + return { + verified: !!deviceKey && deviceKey === pub, + verificationText: deviceKey === pub ? 'Совпадает с deviceKey из PDA.' : 'Не совпадает с deviceKey из PDA.', + }; + } + if (type === 'root.key') { + return { + verified: !!rootKey && rootKey === pub, + verificationText: rootKey === pub ? 'Совпадает с rootKey из PDA.' : 'Не совпадает с rootKey из PDA.', + }; + } + return { + verified: null, + verificationText: 'Для custom-кошелька проверка через PDA пока не выполняется.', + }; +} + +async function requestCurrentWallet() { + const { response, selectedDevice, requestId } = await callWalletRpc({ + v: 1, + operation: 'get_wallet_public_key', + requestId: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + }); + if (!response?.ok) { + throw new Error(`ESP32 отклонил запрос: ${String(response?.error || 'unknown_error')}`); + } + state.currentWallet = { + type: String(response?.wallet?.type || '').trim(), + publicKeyBase58: String(response?.wallet?.publicKeyBase58 || '').trim(), + homeserverName: selectedDevice.sessionName, + requestId: String(response?.requestId || requestId), + timeMs: Number(response?.timeMs || 0), + ...verifyWalletAgainstPda(response?.wallet || {}), + }; + await saveActiveSessionRecord(); + setStatus(`Кошелёк получен с ${selectedDevice.sessionName}.`, 'info'); + return { ok: true, wallet: state.currentWallet }; +} + +async function siteConnect({ origin, onlyIfTrusted = false } = {}) { + const normalizedOrigin = normalizeOrigin(origin); + if (!normalizedOrigin) { + throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN'); + } + if (onlyIfTrusted && !isOriginApproved(normalizedOrigin)) { + throw makeCodeError('Site is not trusted yet.', 'NOT_TRUSTED'); + } + const result = await requestCurrentWallet(); + const publicKeyBase58 = String(result?.wallet?.publicKeyBase58 || '').trim(); + if (!publicKeyBase58) { + throw makeCodeError('Wallet public key is not available.', 'WALLET_UNAVAILABLE'); + } + if (!isOriginApproved(normalizedOrigin)) { + await setOriginApproved(normalizedOrigin, true); + } + setStatus(`Site ${normalizedOrigin} connected to ${shortKey(publicKeyBase58, 8)}.`, 'info'); return { ok: true, - pending: true, + publicKeyBase58, + walletType: String(result?.wallet?.type || '').trim(), + }; +} + +async function siteDisconnect({ origin } = {}) { + const normalizedOrigin = normalizeOrigin(origin); + setStatus(normalizedOrigin ? `Site ${normalizedOrigin} disconnected.` : 'Site disconnected.', 'info'); + return { ok: true }; +} + +async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, comment } = {}) { + const normalizedOrigin = normalizeOrigin(origin); + if (!normalizedOrigin) { + throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN'); + } + if (!isOriginApproved(normalizedOrigin)) { + throw makeCodeError('Site is not trusted yet.', 'NOT_TRUSTED'); + } + const cleanPub = String(publicKeyBase58 || '').trim(); + const cleanTx = String(transactionBase64 || '').trim(); + if (!cleanPub || !cleanTx) { + throw makeCodeError('Transaction payload is incomplete.', 'BAD_REQUEST'); + } + const requestId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + const signComment = String(comment || '').trim() || `Site ${normalizedOrigin} requested transaction signature`; + const { response } = await callWalletRpc({ + v: 1, + operation: 'sign_transaction', + requestId, + publicKeyBase58: cleanPub, + transactionBase64: cleanTx, + comment: signComment, + }, 120000); + if (!response?.ok) { + const errorCode = String(response?.error || 'unknown_error').trim().toUpperCase(); + if (errorCode === 'REJECTED_BY_USER') { + throw makeCodeError('User rejected transaction signature on ESP32.', 'USER_REJECTED'); + } + throw makeCodeError(`ESP32 rejected transaction signature: ${String(response?.error || 'unknown_error')}`, errorCode || 'RPC_REJECTED'); + } + return { + ok: true, + publicKeyBase58: String(response?.publicKeyBase58 || cleanPub).trim(), + signedTransactionBase64: String(response?.signedTransactionBase64 || '').trim(), + signatureBase58: String(response?.signatureBase58 || '').trim(), }; } @@ -485,8 +683,9 @@ function snapshot() { trustedSessionOnline: state.trustedSessionOnline, }, session: state.activeSession ? { ...state.activeSession } : null, - connectionOnline: state.connectionOnline, + connectionOnline: !!state.activeSession, walletProfile: state.walletProfile ? { ...state.walletProfile } : null, + currentWallet: state.currentWallet ? { ...state.currentWallet } : null, signing: { ...state.signing }, status: { text: state.statusText, @@ -538,8 +737,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { sendResponse({ ok: true, result, state: snapshot() }); return; } - if (type === 'wallet:prepareSignSignal') { - const result = await prepareSignSignal(); + if (type === 'wallet:requestCurrentWallet') { + const result = await requestCurrentWallet(); sendResponse({ ok: true, result, state: snapshot() }); return; } @@ -548,15 +747,40 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { sendResponse({ ok: true, result, state: snapshot() }); return; } + if (type === 'wallet:siteConnect') { + const result = await siteConnect(message?.payload || {}); + sendResponse({ ok: true, result, state: snapshot() }); + return; + } + if (type === 'wallet:siteDisconnect') { + const result = await siteDisconnect(message?.payload || {}); + sendResponse({ ok: true, result, state: snapshot() }); + return; + } + if (type === 'wallet:siteSignTransaction') { + const result = await siteSignTransaction(message?.payload || {}); + sendResponse({ ok: true, result, state: snapshot() }); + return; + } sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' }); })().catch((error) => { const message = toWalletErrorMessage(error, 'Unknown error'); setStatus(message, 'error'); - sendResponse({ ok: false, error: message, state: snapshot() }); + sendResponse({ ok: false, error: message, code: String(error?.code || ''), state: snapshot() }); }); return true; }); +chrome.runtime.onInstalled.addListener(() => { + void configureSidePanelBehavior(); +}); + +chrome.runtime.onStartup.addListener(() => { + void configureSidePanelBehavior(); +}); + +void configureSidePanelBehavior(); + void loadStateFromStorage().then(async () => { if (state.activeSession?.login) { await hydrateWalletProfile(state.activeSession.login).catch(() => {}); diff --git a/SHiNE-browser-plugin-wallet/content-script.js b/SHiNE-browser-plugin-wallet/content-script.js new file mode 100644 index 0000000..c2ca1f1 --- /dev/null +++ b/SHiNE-browser-plugin-wallet/content-script.js @@ -0,0 +1,84 @@ +const PAGE_REQUEST = 'shine-wallet-page-request'; +const PAGE_RESPONSE = 'shine-wallet-page-response'; + +function injectProviderBridge() { + const root = document.head || document.documentElement; + if (!root) return; + const script = document.createElement('script'); + script.type = 'module'; + script.src = chrome.runtime.getURL('provider-bridge.js'); + script.dataset.shineWalletProvider = '1'; + root.appendChild(script); + script.remove(); +} + +function respondToPage(id, ok, result, error, code) { + window.postMessage({ + target: PAGE_RESPONSE, + id: String(id || ''), + ok: !!ok, + result: result || null, + error: error ? String(error) : '', + code: code ? String(code) : '', + }, window.location.origin); +} + +function sendRuntimeMessage(type, payload = {}) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type, payload }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message || 'Runtime message failed')); + return; + } + if (!response?.ok) { + const error = new Error(String(response?.error || 'Wallet operation failed')); + error.code = String(response?.code || ''); + reject(error); + return; + } + resolve(response); + }); + }); +} + +window.addEventListener('message', (event) => { + if (event.source !== window) return; + const data = event.data || {}; + if (data?.target !== PAGE_REQUEST) return; + + const id = String(data?.id || ''); + const method = String(data?.method || ''); + const params = data?.params || {}; + const origin = window.location.origin; + + (async () => { + if (method === 'connect') { + const response = await sendRuntimeMessage('wallet:siteConnect', { + origin, + onlyIfTrusted: !!params?.onlyIfTrusted, + }); + respondToPage(id, true, response.result || null); + return; + } + if (method === 'disconnect') { + const response = await sendRuntimeMessage('wallet:siteDisconnect', { origin }); + respondToPage(id, true, response.result || null); + return; + } + if (method === 'signTransaction') { + const response = await sendRuntimeMessage('wallet:siteSignTransaction', { + origin, + publicKeyBase58: String(params?.publicKeyBase58 || '').trim(), + transactionBase64: String(params?.transactionBase64 || '').trim(), + comment: String(params?.comment || '').trim(), + }); + respondToPage(id, true, response.result || null); + return; + } + respondToPage(id, false, null, 'Unsupported provider method', 'UNSUPPORTED_METHOD'); + })().catch((error) => { + respondToPage(id, false, null, error?.message || 'Wallet bridge error', error?.code || ''); + }); +}); + +injectProviderBridge(); diff --git a/SHiNE-browser-plugin-wallet/js/lib/shine-api.js b/SHiNE-browser-plugin-wallet/js/lib/shine-api.js index e665425..980a129 100644 --- a/SHiNE-browser-plugin-wallet/js/lib/shine-api.js +++ b/SHiNE-browser-plugin-wallet/js/lib/shine-api.js @@ -75,6 +75,22 @@ export class ShineApiClient { return Array.isArray(response?.payload?.sessions) ? response.payload.sessions : []; } + async callSignalToSession({ toLogin, targetSessionId, callId, type, data = '' }) { + const response = await this.ws.request('CallSignalToSession', { + toLogin: String(toLogin || '').trim(), + targetSessionId: String(targetSessionId || '').trim(), + callId: String(callId || '').trim(), + type: Number(type) || 0, + data: String(data || ''), + }); + if (response.status !== 200) throw opError('CallSignalToSession', response); + return response.payload || {}; + } + + onEvent(op, handler) { + return this.ws.on(op, handler); + } + async resumeSession(sessionRecord) { const login = String(sessionRecord?.login || '').trim(); const sessionId = String(sessionRecord?.sessionId || '').trim(); diff --git a/SHiNE-browser-plugin-wallet/js/lib/vendor/solana-publickey-bundle.js b/SHiNE-browser-plugin-wallet/js/lib/vendor/solana-publickey-bundle.js index f5abc85..45564ec 100644 --- a/SHiNE-browser-plugin-wallet/js/lib/vendor/solana-publickey-bundle.js +++ b/SHiNE-browser-plugin-wallet/js/lib/vendor/solana-publickey-bundle.js @@ -12136,8 +12136,8 @@ function weierstrass(curveDef) { return drbg(seed, k2sig); } Point2.BASE._setWindowSize(8); - function verify2(signature, msgHash, publicKey2, opts = defaultVerOpts) { - const sg = signature; + function verify2(signature2, msgHash, publicKey2, opts = defaultVerOpts) { + const sg = signature2; msgHash = ensureBytes("msgHash", msgHash); publicKey2 = ensureBytes("publicKey", publicKey2); if ("strict" in opts) @@ -12515,30 +12515,30 @@ var PACKET_DATA_SIZE = 1280 - 40 - 8; var VERSION_PREFIX_MASK = 127; var SIGNATURE_LENGTH_IN_BYTES = 64; var TransactionExpiredBlockheightExceededError = class extends Error { - constructor(signature) { - super(`Signature ${signature} has expired: block height exceeded.`); + constructor(signature2) { + super(`Signature ${signature2} has expired: block height exceeded.`); this.signature = void 0; - this.signature = signature; + this.signature = signature2; } }; Object.defineProperty(TransactionExpiredBlockheightExceededError.prototype, "name", { value: "TransactionExpiredBlockheightExceededError" }); var TransactionExpiredTimeoutError = class extends Error { - constructor(signature, timeoutSeconds) { - super(`Transaction was not confirmed in ${timeoutSeconds.toFixed(2)} seconds. It is unknown if it succeeded or failed. Check signature ${signature} using the Solana Explorer or CLI tools.`); + constructor(signature2, timeoutSeconds) { + super(`Transaction was not confirmed in ${timeoutSeconds.toFixed(2)} seconds. It is unknown if it succeeded or failed. Check signature ${signature2} using the Solana Explorer or CLI tools.`); this.signature = void 0; - this.signature = signature; + this.signature = signature2; } }; Object.defineProperty(TransactionExpiredTimeoutError.prototype, "name", { value: "TransactionExpiredTimeoutError" }); var TransactionExpiredNonceInvalidError = class extends Error { - constructor(signature) { - super(`Signature ${signature} has expired: the nonce is no longer valid.`); + constructor(signature2) { + super(`Signature ${signature2} has expired: the nonce is no longer valid.`); this.signature = void 0; - this.signature = signature; + this.signature = signature2; } }; Object.defineProperty(TransactionExpiredNonceInvalidError.prototype, "name", { @@ -12598,6 +12598,9 @@ var MessageAccountKeys = class { var publicKey = (property = "publicKey") => { return BufferLayout.blob(32, property); }; +var signature = (property = "signature") => { + return BufferLayout.blob(64, property); +}; var rustString = (property = "string") => { const rsl = BufferLayout.struct([BufferLayout.u32("length"), BufferLayout.u32("lengthPadding"), BufferLayout.blob(BufferLayout.offset(BufferLayout.u32(), -8), "chars")], property); const _decode = rsl.decode.bind(rsl); @@ -12954,6 +12957,260 @@ var Message = class _Message { return new _Message(messageArgs); } }; +var MessageV0 = class _MessageV0 { + constructor(args) { + this.header = void 0; + this.staticAccountKeys = void 0; + this.recentBlockhash = void 0; + this.compiledInstructions = void 0; + this.addressTableLookups = void 0; + this.header = args.header; + this.staticAccountKeys = args.staticAccountKeys; + this.recentBlockhash = args.recentBlockhash; + this.compiledInstructions = args.compiledInstructions; + this.addressTableLookups = args.addressTableLookups; + } + get version() { + return 0; + } + get numAccountKeysFromLookups() { + let count = 0; + for (const lookup of this.addressTableLookups) { + count += lookup.readonlyIndexes.length + lookup.writableIndexes.length; + } + return count; + } + getAccountKeys(args) { + let accountKeysFromLookups; + if (args && "accountKeysFromLookups" in args && args.accountKeysFromLookups) { + if (this.numAccountKeysFromLookups != args.accountKeysFromLookups.writable.length + args.accountKeysFromLookups.readonly.length) { + throw new Error("Failed to get account keys because of a mismatch in the number of account keys from lookups"); + } + accountKeysFromLookups = args.accountKeysFromLookups; + } else if (args && "addressLookupTableAccounts" in args && args.addressLookupTableAccounts) { + accountKeysFromLookups = this.resolveAddressTableLookups(args.addressLookupTableAccounts); + } else if (this.addressTableLookups.length > 0) { + throw new Error("Failed to get account keys because address table lookups were not resolved"); + } + return new MessageAccountKeys(this.staticAccountKeys, accountKeysFromLookups); + } + isAccountSigner(index) { + return index < this.header.numRequiredSignatures; + } + isAccountWritable(index) { + const numSignedAccounts = this.header.numRequiredSignatures; + const numStaticAccountKeys = this.staticAccountKeys.length; + if (index >= numStaticAccountKeys) { + const lookupAccountKeysIndex = index - numStaticAccountKeys; + const numWritableLookupAccountKeys = this.addressTableLookups.reduce((count, lookup) => count + lookup.writableIndexes.length, 0); + return lookupAccountKeysIndex < numWritableLookupAccountKeys; + } else if (index >= this.header.numRequiredSignatures) { + const unsignedAccountIndex = index - numSignedAccounts; + const numUnsignedAccounts = numStaticAccountKeys - numSignedAccounts; + const numWritableUnsignedAccounts = numUnsignedAccounts - this.header.numReadonlyUnsignedAccounts; + return unsignedAccountIndex < numWritableUnsignedAccounts; + } else { + const numWritableSignedAccounts = numSignedAccounts - this.header.numReadonlySignedAccounts; + return index < numWritableSignedAccounts; + } + } + resolveAddressTableLookups(addressLookupTableAccounts) { + const accountKeysFromLookups = { + writable: [], + readonly: [] + }; + for (const tableLookup of this.addressTableLookups) { + const tableAccount = addressLookupTableAccounts.find((account) => account.key.equals(tableLookup.accountKey)); + if (!tableAccount) { + throw new Error(`Failed to find address lookup table account for table key ${tableLookup.accountKey.toBase58()}`); + } + for (const index of tableLookup.writableIndexes) { + if (index < tableAccount.state.addresses.length) { + accountKeysFromLookups.writable.push(tableAccount.state.addresses[index]); + } else { + throw new Error(`Failed to find address for index ${index} in address lookup table ${tableLookup.accountKey.toBase58()}`); + } + } + for (const index of tableLookup.readonlyIndexes) { + if (index < tableAccount.state.addresses.length) { + accountKeysFromLookups.readonly.push(tableAccount.state.addresses[index]); + } else { + throw new Error(`Failed to find address for index ${index} in address lookup table ${tableLookup.accountKey.toBase58()}`); + } + } + } + return accountKeysFromLookups; + } + static compile(args) { + const compiledKeys = CompiledKeys.compile(args.instructions, args.payerKey); + const addressTableLookups = new Array(); + const accountKeysFromLookups = { + writable: new Array(), + readonly: new Array() + }; + const lookupTableAccounts = args.addressLookupTableAccounts || []; + for (const lookupTable of lookupTableAccounts) { + const extractResult = compiledKeys.extractTableLookup(lookupTable); + if (extractResult !== void 0) { + const [addressTableLookup, { + writable, + readonly + }] = extractResult; + addressTableLookups.push(addressTableLookup); + accountKeysFromLookups.writable.push(...writable); + accountKeysFromLookups.readonly.push(...readonly); + } + } + const [header, staticAccountKeys] = compiledKeys.getMessageComponents(); + const accountKeys = new MessageAccountKeys(staticAccountKeys, accountKeysFromLookups); + const compiledInstructions = accountKeys.compileInstructions(args.instructions); + return new _MessageV0({ + header, + staticAccountKeys, + recentBlockhash: args.recentBlockhash, + compiledInstructions, + addressTableLookups + }); + } + serialize() { + const encodedStaticAccountKeysLength = Array(); + encodeLength(encodedStaticAccountKeysLength, this.staticAccountKeys.length); + const serializedInstructions = this.serializeInstructions(); + const encodedInstructionsLength = Array(); + encodeLength(encodedInstructionsLength, this.compiledInstructions.length); + const serializedAddressTableLookups = this.serializeAddressTableLookups(); + const encodedAddressTableLookupsLength = Array(); + encodeLength(encodedAddressTableLookupsLength, this.addressTableLookups.length); + const messageLayout = BufferLayout.struct([BufferLayout.u8("prefix"), BufferLayout.struct([BufferLayout.u8("numRequiredSignatures"), BufferLayout.u8("numReadonlySignedAccounts"), BufferLayout.u8("numReadonlyUnsignedAccounts")], "header"), BufferLayout.blob(encodedStaticAccountKeysLength.length, "staticAccountKeysLength"), BufferLayout.seq(publicKey(), this.staticAccountKeys.length, "staticAccountKeys"), publicKey("recentBlockhash"), BufferLayout.blob(encodedInstructionsLength.length, "instructionsLength"), BufferLayout.blob(serializedInstructions.length, "serializedInstructions"), BufferLayout.blob(encodedAddressTableLookupsLength.length, "addressTableLookupsLength"), BufferLayout.blob(serializedAddressTableLookups.length, "serializedAddressTableLookups")]); + const serializedMessage = new Uint8Array(PACKET_DATA_SIZE); + const MESSAGE_VERSION_0_PREFIX = 1 << 7; + const serializedMessageLength = messageLayout.encode({ + prefix: MESSAGE_VERSION_0_PREFIX, + header: this.header, + staticAccountKeysLength: new Uint8Array(encodedStaticAccountKeysLength), + staticAccountKeys: this.staticAccountKeys.map((key) => key.toBytes()), + recentBlockhash: import_bs58.default.decode(this.recentBlockhash), + instructionsLength: new Uint8Array(encodedInstructionsLength), + serializedInstructions, + addressTableLookupsLength: new Uint8Array(encodedAddressTableLookupsLength), + serializedAddressTableLookups + }, serializedMessage); + return serializedMessage.slice(0, serializedMessageLength); + } + serializeInstructions() { + let serializedLength = 0; + const serializedInstructions = new Uint8Array(PACKET_DATA_SIZE); + for (const instruction of this.compiledInstructions) { + const encodedAccountKeyIndexesLength = Array(); + encodeLength(encodedAccountKeyIndexesLength, instruction.accountKeyIndexes.length); + const encodedDataLength = Array(); + encodeLength(encodedDataLength, instruction.data.length); + const instructionLayout = BufferLayout.struct([BufferLayout.u8("programIdIndex"), BufferLayout.blob(encodedAccountKeyIndexesLength.length, "encodedAccountKeyIndexesLength"), BufferLayout.seq(BufferLayout.u8(), instruction.accountKeyIndexes.length, "accountKeyIndexes"), BufferLayout.blob(encodedDataLength.length, "encodedDataLength"), BufferLayout.blob(instruction.data.length, "data")]); + serializedLength += instructionLayout.encode({ + programIdIndex: instruction.programIdIndex, + encodedAccountKeyIndexesLength: new Uint8Array(encodedAccountKeyIndexesLength), + accountKeyIndexes: instruction.accountKeyIndexes, + encodedDataLength: new Uint8Array(encodedDataLength), + data: instruction.data + }, serializedInstructions, serializedLength); + } + return serializedInstructions.slice(0, serializedLength); + } + serializeAddressTableLookups() { + let serializedLength = 0; + const serializedAddressTableLookups = new Uint8Array(PACKET_DATA_SIZE); + for (const lookup of this.addressTableLookups) { + const encodedWritableIndexesLength = Array(); + encodeLength(encodedWritableIndexesLength, lookup.writableIndexes.length); + const encodedReadonlyIndexesLength = Array(); + encodeLength(encodedReadonlyIndexesLength, lookup.readonlyIndexes.length); + const addressTableLookupLayout = BufferLayout.struct([publicKey("accountKey"), BufferLayout.blob(encodedWritableIndexesLength.length, "encodedWritableIndexesLength"), BufferLayout.seq(BufferLayout.u8(), lookup.writableIndexes.length, "writableIndexes"), BufferLayout.blob(encodedReadonlyIndexesLength.length, "encodedReadonlyIndexesLength"), BufferLayout.seq(BufferLayout.u8(), lookup.readonlyIndexes.length, "readonlyIndexes")]); + serializedLength += addressTableLookupLayout.encode({ + accountKey: lookup.accountKey.toBytes(), + encodedWritableIndexesLength: new Uint8Array(encodedWritableIndexesLength), + writableIndexes: lookup.writableIndexes, + encodedReadonlyIndexesLength: new Uint8Array(encodedReadonlyIndexesLength), + readonlyIndexes: lookup.readonlyIndexes + }, serializedAddressTableLookups, serializedLength); + } + return serializedAddressTableLookups.slice(0, serializedLength); + } + static deserialize(serializedMessage) { + let byteArray = [...serializedMessage]; + const prefix = guardedShift(byteArray); + const maskedPrefix = prefix & VERSION_PREFIX_MASK; + assert2(prefix !== maskedPrefix, `Expected versioned message but received legacy message`); + const version2 = maskedPrefix; + assert2(version2 === 0, `Expected versioned message with version 0 but found version ${version2}`); + const header = { + numRequiredSignatures: guardedShift(byteArray), + numReadonlySignedAccounts: guardedShift(byteArray), + numReadonlyUnsignedAccounts: guardedShift(byteArray) + }; + const staticAccountKeys = []; + const staticAccountKeysLength = decodeLength(byteArray); + for (let i2 = 0; i2 < staticAccountKeysLength; i2++) { + staticAccountKeys.push(new PublicKey(guardedSplice(byteArray, 0, PUBLIC_KEY_LENGTH))); + } + const recentBlockhash = import_bs58.default.encode(guardedSplice(byteArray, 0, PUBLIC_KEY_LENGTH)); + const instructionCount = decodeLength(byteArray); + const compiledInstructions = []; + for (let i2 = 0; i2 < instructionCount; i2++) { + const programIdIndex = guardedShift(byteArray); + const accountKeyIndexesLength = decodeLength(byteArray); + const accountKeyIndexes = guardedSplice(byteArray, 0, accountKeyIndexesLength); + const dataLength = decodeLength(byteArray); + const data = new Uint8Array(guardedSplice(byteArray, 0, dataLength)); + compiledInstructions.push({ + programIdIndex, + accountKeyIndexes, + data + }); + } + const addressTableLookupsCount = decodeLength(byteArray); + const addressTableLookups = []; + for (let i2 = 0; i2 < addressTableLookupsCount; i2++) { + const accountKey = new PublicKey(guardedSplice(byteArray, 0, PUBLIC_KEY_LENGTH)); + const writableIndexesLength = decodeLength(byteArray); + const writableIndexes = guardedSplice(byteArray, 0, writableIndexesLength); + const readonlyIndexesLength = decodeLength(byteArray); + const readonlyIndexes = guardedSplice(byteArray, 0, readonlyIndexesLength); + addressTableLookups.push({ + accountKey, + writableIndexes, + readonlyIndexes + }); + } + return new _MessageV0({ + header, + staticAccountKeys, + recentBlockhash, + compiledInstructions, + addressTableLookups + }); + } +}; +var VersionedMessage = { + deserializeMessageVersion(serializedMessage) { + const prefix = serializedMessage[0]; + const maskedPrefix = prefix & VERSION_PREFIX_MASK; + if (maskedPrefix === prefix) { + return "legacy"; + } + return maskedPrefix; + }, + deserialize: (serializedMessage) => { + const version2 = VersionedMessage.deserializeMessageVersion(serializedMessage); + if (version2 === "legacy") { + return Message.from(serializedMessage); + } + if (version2 === 0) { + return MessageV0.deserialize(serializedMessage); + } else { + throw new Error(`Transaction message version ${version2} deserialization is not supported`); + } + } +}; var DEFAULT_SIGNATURE = import_buffer2.Buffer.alloc(SIGNATURE_LENGTH_IN_BYTES).fill(0); var TransactionInstruction = class { constructor(opts) { @@ -13196,9 +13453,9 @@ var Transaction = class _Transaction { isWritable: true }); } - for (const signature of this.signatures) { + for (const signature2 of this.signatures) { const uniqueIndex = uniqueMetas.findIndex((x) => { - return x.pubkey.equals(signature.publicKey); + return x.pubkey.equals(signature2.publicKey); }); if (uniqueIndex > -1) { if (!uniqueMetas[uniqueIndex].isSigner) { @@ -13206,7 +13463,7 @@ var Transaction = class _Transaction { console.warn("Transaction references a signature that is unnecessary, only the fee payer and instruction signer accounts should sign a transaction. This behavior is deprecated and will throw an error in the next major version release."); } } else { - throw new Error(`unknown signer: ${signature.publicKey.toString()}`); + throw new Error(`unknown signer: ${signature2.publicKey.toString()}`); } } let numRequiredSignatures = 0; @@ -13392,8 +13649,8 @@ var Transaction = class _Transaction { _partialSign(message, ...signers) { const signData = message.serialize(); signers.forEach((signer) => { - const signature = sign(signData, signer.secretKey); - this._addSignature(signer.publicKey, toBuffer(signature)); + const signature2 = sign(signData, signer.secretKey); + this._addSignature(signer.publicKey, toBuffer(signature2)); }); } /** @@ -13404,20 +13661,20 @@ var Transaction = class _Transaction { * @param {PublicKey} pubkey Public key that will be added to the transaction. * @param {Buffer} signature An externally created signature to add to the transaction. */ - addSignature(pubkey, signature) { + addSignature(pubkey, signature2) { this._compile(); - this._addSignature(pubkey, signature); + this._addSignature(pubkey, signature2); } /** * @internal */ - _addSignature(pubkey, signature) { - assert2(signature.length === 64); + _addSignature(pubkey, signature2) { + assert2(signature2.length === 64); const index = this.signatures.findIndex((sigpair) => pubkey.equals(sigpair.publicKey)); if (index < 0) { throw new Error(`unknown signer: ${pubkey.toString()}`); } - this.signatures[index].signature = import_buffer2.Buffer.from(signature); + this.signatures[index].signature = import_buffer2.Buffer.from(signature2); } /** * Verify signatures of a Transaction @@ -13436,15 +13693,15 @@ var Transaction = class _Transaction { _getMessageSignednessErrors(message, requireAllSignatures) { const errors = {}; for (const { - signature, + signature: signature2, publicKey: publicKey2 } of this.signatures) { - if (signature === null) { + if (signature2 === null) { if (requireAllSignatures) { (errors.missing ||= []).push(publicKey2); } } else { - if (!verify(signature, message, publicKey2.toBytes())) { + if (!verify(signature2, message, publicKey2.toBytes())) { (errors.invalid ||= []).push(publicKey2); } } @@ -13498,11 +13755,11 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [ assert2(signatures.length < 256); import_buffer2.Buffer.from(signatureCount).copy(wireTransaction, 0); signatures.forEach(({ - signature + signature: signature2 }, index) => { - if (signature !== null) { - assert2(signature.length === 64, `signature has invalid length`); - import_buffer2.Buffer.from(signature).copy(wireTransaction, signatureCount.length + index * 64); + if (signature2 !== null) { + assert2(signature2.length === 64, `signature has invalid length`); + import_buffer2.Buffer.from(signature2).copy(wireTransaction, signatureCount.length + index * 64); } }); signData.copy(wireTransaction, signatureCount.length + signatures.length * 64); @@ -13545,8 +13802,8 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [ const signatureCount = decodeLength(byteArray); let signatures = []; for (let i2 = 0; i2 < signatureCount; i2++) { - const signature = guardedSplice(byteArray, 0, SIGNATURE_LENGTH_IN_BYTES); - signatures.push(import_bs58.default.encode(import_buffer2.Buffer.from(signature))); + const signature2 = guardedSplice(byteArray, 0, SIGNATURE_LENGTH_IN_BYTES); + signatures.push(import_bs58.default.encode(import_buffer2.Buffer.from(signature2))); } return _Transaction.populate(Message.from(byteArray), signatures); } @@ -13564,9 +13821,9 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [ if (message.header.numRequiredSignatures > 0) { transaction.feePayer = message.accountKeys[0]; } - signatures.forEach((signature, index) => { + signatures.forEach((signature2, index) => { const sigPubkeyPair = { - signature: signature == import_bs58.default.encode(DEFAULT_SIGNATURE) ? null : import_bs58.default.decode(signature), + signature: signature2 == import_bs58.default.encode(DEFAULT_SIGNATURE) ? null : import_bs58.default.decode(signature2), publicKey: message.accountKeys[index] }; transaction.signatures.push(sigPubkeyPair); @@ -13591,6 +13848,65 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [ return transaction; } }; +var VersionedTransaction = class _VersionedTransaction { + get version() { + return this.message.version; + } + constructor(message, signatures) { + this.signatures = void 0; + this.message = void 0; + if (signatures !== void 0) { + assert2(signatures.length === message.header.numRequiredSignatures, "Expected signatures length to be equal to the number of required signatures"); + this.signatures = signatures; + } else { + const defaultSignatures = []; + for (let i2 = 0; i2 < message.header.numRequiredSignatures; i2++) { + defaultSignatures.push(new Uint8Array(SIGNATURE_LENGTH_IN_BYTES)); + } + this.signatures = defaultSignatures; + } + this.message = message; + } + serialize() { + const serializedMessage = this.message.serialize(); + const encodedSignaturesLength = Array(); + encodeLength(encodedSignaturesLength, this.signatures.length); + const transactionLayout = BufferLayout.struct([BufferLayout.blob(encodedSignaturesLength.length, "encodedSignaturesLength"), BufferLayout.seq(signature(), this.signatures.length, "signatures"), BufferLayout.blob(serializedMessage.length, "serializedMessage")]); + const serializedTransaction = new Uint8Array(2048); + const serializedTransactionLength = transactionLayout.encode({ + encodedSignaturesLength: new Uint8Array(encodedSignaturesLength), + signatures: this.signatures, + serializedMessage + }, serializedTransaction); + return serializedTransaction.slice(0, serializedTransactionLength); + } + static deserialize(serializedTransaction) { + let byteArray = [...serializedTransaction]; + const signatures = []; + const signaturesLength = decodeLength(byteArray); + for (let i2 = 0; i2 < signaturesLength; i2++) { + signatures.push(new Uint8Array(guardedSplice(byteArray, 0, SIGNATURE_LENGTH_IN_BYTES))); + } + const message = VersionedMessage.deserialize(new Uint8Array(byteArray)); + return new _VersionedTransaction(message, signatures); + } + sign(signers) { + const messageData = this.message.serialize(); + const signerPubkeys = this.message.staticAccountKeys.slice(0, this.message.header.numRequiredSignatures); + for (const signer of signers) { + const signerIndex = signerPubkeys.findIndex((pubkey) => pubkey.equals(signer.publicKey)); + assert2(signerIndex >= 0, `Cannot sign with non signer key ${signer.publicKey.toBase58()}`); + this.signatures[signerIndex] = sign(messageData, signer.secretKey); + } + } + addSignature(publicKey2, signature2) { + assert2(signature2.byteLength === 64, "Signature must be 64 bytes long"); + const signerPubkeys = this.message.staticAccountKeys.slice(0, this.message.header.numRequiredSignatures); + const signerIndex = signerPubkeys.findIndex((pubkey) => pubkey.equals(publicKey2)); + assert2(signerIndex >= 0, `Can not add signature; \`${publicKey2.toBase58()}\` is not required to sign this transaction`); + this.signatures[signerIndex] = signature2; + } +}; var NUM_TICKS_PER_SECOND = 160; var DEFAULT_TICKS_PER_SLOT = 64; var NUM_SLOTS_PER_SECOND = NUM_TICKS_PER_SECOND / DEFAULT_TICKS_PER_SLOT; @@ -13607,7 +13923,7 @@ var SYSVAR_STAKE_HISTORY_PUBKEY = new PublicKey("SysvarStakeHistory1111111111111 var SendTransactionError = class extends Error { constructor({ action, - signature, + signature: signature2, transactionMessage, logs }) { @@ -13617,7 +13933,7 @@ ${JSON.stringify(logs.slice(-10), null, 2)}. ` : ""; let message; switch (action) { case "send": - message = `Transaction ${signature} resulted in an error. + message = `Transaction ${signature2} resulted in an error. ${transactionMessage}. ` + maybeLogsOutput + guideText; break; case "simulate": @@ -13633,7 +13949,7 @@ Message: ${transactionMessage}. this.signature = void 0; this.transactionMessage = void 0; this.transactionLogs = void 0; - this.signature = signature; + this.signature = signature2; this.transactionMessage = transactionMessage; this.transactionLogs = logs ? logs : void 0; } @@ -13675,12 +13991,12 @@ async function sendAndConfirmTransaction(connection, transaction, signers, optio maxRetries: options.maxRetries, minContextSlot: options.minContextSlot }; - const signature = await connection.sendTransaction(transaction, signers, sendOptions); + const signature2 = await connection.sendTransaction(transaction, signers, sendOptions); let status; if (transaction.recentBlockhash != null && transaction.lastValidBlockHeight != null) { status = (await connection.confirmTransaction({ abortSignal: options?.abortSignal, - signature, + signature: signature2, blockhash: transaction.recentBlockhash, lastValidBlockHeight: transaction.lastValidBlockHeight }, options && options.commitment)).value; @@ -13694,25 +14010,25 @@ async function sendAndConfirmTransaction(connection, transaction, signers, optio minContextSlot: transaction.minNonceContextSlot, nonceAccountPubkey, nonceValue: transaction.nonceInfo.nonce, - signature + signature: signature2 }, options && options.commitment)).value; } else { if (options?.abortSignal != null) { console.warn("sendAndConfirmTransaction(): A transaction with a deprecated confirmation strategy was supplied along with an `abortSignal`. Only transactions having `lastValidBlockHeight` or a combination of `nonceInfo` and `minNonceContextSlot` are abortable."); } - status = (await connection.confirmTransaction(signature, options && options.commitment)).value; + status = (await connection.confirmTransaction(signature2, options && options.commitment)).value; } if (status.err) { - if (signature != null) { + if (signature2 != null) { throw new SendTransactionError({ action: "send", - signature, + signature: signature2, transactionMessage: `Status: (${JSON.stringify(status)})` }); } - throw new Error(`Transaction ${signature} failed (${JSON.stringify(status)})`); + throw new Error(`Transaction ${signature2} failed (${JSON.stringify(status)})`); } - return signature; + return signature2; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -15218,14 +15534,14 @@ var Ed25519Program = class _Ed25519Program { const { publicKey: publicKey2, message, - signature, + signature: signature2, instructionIndex } = params; assert2(publicKey2.length === PUBLIC_KEY_BYTES$1, `Public Key must be ${PUBLIC_KEY_BYTES$1} bytes but received ${publicKey2.length} bytes`); - assert2(signature.length === SIGNATURE_BYTES, `Signature must be ${SIGNATURE_BYTES} bytes but received ${signature.length} bytes`); + assert2(signature2.length === SIGNATURE_BYTES, `Signature must be ${SIGNATURE_BYTES} bytes but received ${signature2.length} bytes`); const publicKeyOffset = ED25519_INSTRUCTION_LAYOUT.span; const signatureOffset = publicKeyOffset + publicKey2.length; - const messageDataOffset = signatureOffset + signature.length; + const messageDataOffset = signatureOffset + signature2.length; const numSignatures = 1; const instructionData = import_buffer2.Buffer.alloc(messageDataOffset + message.length); const index = instructionIndex == null ? 65535 : instructionIndex; @@ -15241,7 +15557,7 @@ var Ed25519Program = class _Ed25519Program { messageInstructionIndex: index }, instructionData); instructionData.fill(publicKey2, publicKeyOffset); - instructionData.fill(signature, signatureOffset); + instructionData.fill(signature2, signatureOffset); instructionData.fill(message, messageDataOffset); return new TransactionInstruction({ keys: [], @@ -15263,11 +15579,11 @@ var Ed25519Program = class _Ed25519Program { try { const keypair = Keypair.fromSecretKey(privateKey); const publicKey2 = keypair.publicKey.toBytes(); - const signature = sign(message, keypair.secretKey); + const signature2 = sign(message, keypair.secretKey); return this.createInstructionWithPublicKey({ publicKey: publicKey2, message, - signature, + signature: signature2, instructionIndex }); } catch (error) { @@ -15277,8 +15593,8 @@ var Ed25519Program = class _Ed25519Program { }; Ed25519Program.programId = new PublicKey("Ed25519SigVerify111111111111111111111111111"); var ecdsaSign = (msgHash, privKey) => { - const signature = secp256k1.sign(msgHash, privKey); - return [signature.toCompactRawBytes(), signature.recovery]; + const signature2 = secp256k1.sign(msgHash, privKey); + return [signature2.toCompactRawBytes(), signature2.recovery]; }; secp256k1.utils.isValidPrivateKey; var publicKeyCreate = secp256k1.getPublicKey; @@ -15316,14 +15632,14 @@ var Secp256k1Program = class _Secp256k1Program { const { publicKey: publicKey2, message, - signature, + signature: signature2, recoveryId, instructionIndex } = params; return _Secp256k1Program.createInstructionWithEthAddress({ ethAddress: _Secp256k1Program.publicKeyToEthAddress(publicKey2), message, - signature, + signature: signature2, recoveryId, instructionIndex }); @@ -15336,7 +15652,7 @@ var Secp256k1Program = class _Secp256k1Program { const { ethAddress: rawAddress, message, - signature, + signature: signature2, recoveryId, instructionIndex = 0 } = params; @@ -15354,7 +15670,7 @@ var Secp256k1Program = class _Secp256k1Program { const dataStart = 1 + SIGNATURE_OFFSETS_SERIALIZED_SIZE; const ethAddressOffset = dataStart; const signatureOffset = dataStart + ethAddress.length; - const messageDataOffset = signatureOffset + signature.length + 1; + const messageDataOffset = signatureOffset + signature2.length + 1; const numSignatures = 1; const instructionData = import_buffer2.Buffer.alloc(SECP256K1_INSTRUCTION_LAYOUT.span + message.length); SECP256K1_INSTRUCTION_LAYOUT.encode({ @@ -15366,7 +15682,7 @@ var Secp256k1Program = class _Secp256k1Program { messageDataOffset, messageDataSize: message.length, messageInstructionIndex: instructionIndex, - signature: toBuffer(signature), + signature: toBuffer(signature2), ethAddress: toBuffer(ethAddress), recoveryId }, instructionData); @@ -15396,11 +15712,11 @@ var Secp256k1Program = class _Secp256k1Program { /* isCompressed */ ).slice(1); const messageHash = import_buffer2.Buffer.from(keccak_256(toBuffer(message))); - const [signature, recoveryId] = ecdsaSign(messageHash, privateKey); + const [signature2, recoveryId] = ecdsaSign(messageHash, privateKey); return this.createInstructionWithPublicKey({ publicKey: publicKey2, message, - signature, + signature: signature2, recoveryId, instructionIndex }); @@ -16179,7 +16495,9 @@ var VoteAccountLayout = BufferLayout.struct([ BufferLayout.struct([BufferLayout.nu64("slot"), BufferLayout.nu64("timestamp")], "lastTimestamp") ]); export { - PublicKey + PublicKey, + Transaction, + VersionedTransaction }; /*! Bundled license information: diff --git a/SHiNE-browser-plugin-wallet/js/lib/vendor/solana-publickey-entry.js b/SHiNE-browser-plugin-wallet/js/lib/vendor/solana-publickey-entry.js index 3acaa4a..9be3f2e 100644 --- a/SHiNE-browser-plugin-wallet/js/lib/vendor/solana-publickey-entry.js +++ b/SHiNE-browser-plugin-wallet/js/lib/vendor/solana-publickey-entry.js @@ -1,3 +1,3 @@ -import { PublicKey } from '@solana/web3.js'; +import { PublicKey, Transaction, VersionedTransaction } from '@solana/web3.js'; -export { PublicKey }; +export { PublicKey, Transaction, VersionedTransaction }; diff --git a/SHiNE-browser-plugin-wallet/js/lib/ws-client.js b/SHiNE-browser-plugin-wallet/js/lib/ws-client.js index e5f7de6..4c92434 100644 --- a/SHiNE-browser-plugin-wallet/js/lib/ws-client.js +++ b/SHiNE-browser-plugin-wallet/js/lib/ws-client.js @@ -24,6 +24,7 @@ export class WsJsonClient { this.ws = null; this.openPromise = null; this.pending = new Map(); + this.listeners = new Map(); } async open() { @@ -78,14 +79,53 @@ export class WsJsonClient { } catch { return; } + if (data?.event) { + this.emit(String(data?.op || ''), data); + return; + } const requestId = data?.requestId; - if (!requestId) return; + if (!requestId) { + this.emit(String(data?.op || ''), data); + return; + } const slot = this.pending.get(requestId); - if (!slot) return; + if (!slot) { + this.emit(String(data?.op || ''), data); + return; + } this.pending.delete(requestId); slot.resolve(data); } + on(op, handler) { + const key = String(op || '').trim(); + if (!key || typeof handler !== 'function') { + return () => {}; + } + if (!this.listeners.has(key)) { + this.listeners.set(key, new Set()); + } + this.listeners.get(key).add(handler); + return () => { + const bucket = this.listeners.get(key); + if (!bucket) return; + bucket.delete(handler); + if (!bucket.size) { + this.listeners.delete(key); + } + }; + } + + emit(op, payload) { + const bucket = this.listeners.get(String(op || '').trim()); + if (!bucket?.size) return; + for (const handler of [...bucket]) { + try { + handler(payload); + } catch {} + } + } + failPending(message) { const error = new Error(message); for (const slot of this.pending.values()) slot.reject(error); diff --git a/SHiNE-browser-plugin-wallet/manifest.json b/SHiNE-browser-plugin-wallet/manifest.json index fb5dd61..3113a04 100644 --- a/SHiNE-browser-plugin-wallet/manifest.json +++ b/SHiNE-browser-plugin-wallet/manifest.json @@ -4,7 +4,8 @@ "version": "0.1.0", "description": "Wallet-session plugin for SHiNE with session-only login via trusted device.", "permissions": [ - "storage" + "storage", + "sidePanel" ], "host_permissions": [ "" @@ -13,8 +14,32 @@ "service_worker": "background.js", "type": "module" }, + "content_scripts": [ + { + "matches": [ + "" + ], + "js": [ + "content-script.js" + ], + "run_at": "document_start" + } + ], + "web_accessible_resources": [ + { + "resources": [ + "provider-bridge.js", + "js/lib/vendor/solana-publickey-bundle.js" + ], + "matches": [ + "" + ] + } + ], "action": { - "default_title": "SHiNE Wallet", - "default_popup": "popup.html" + "default_title": "Open SHiNE Wallet" + }, + "side_panel": { + "default_path": "popup.html" } } diff --git a/SHiNE-browser-plugin-wallet/popup.css b/SHiNE-browser-plugin-wallet/popup.css index 7123418..fe1e0f5 100644 --- a/SHiNE-browser-plugin-wallet/popup.css +++ b/SHiNE-browser-plugin-wallet/popup.css @@ -2,22 +2,34 @@ box-sizing: border-box; } +html { + min-height: 100%; +} + body { margin: 0; - min-width: 360px; + min-width: 320px; + max-width: none; + min-height: 100vh; background: #0f1720; color: #e8eef6; font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +.side-panel-body { + width: 100%; +} + .layout { padding: 12px; + min-height: 100vh; } .panel { display: flex; flex-direction: column; gap: 12px; + min-height: calc(100vh - 24px); } .panel-header { @@ -140,9 +152,9 @@ select { } .code { - font-size: 34px; + font-size: 30px; font-weight: 700; - letter-spacing: 0.18em; + letter-spacing: 0.12em; } .summary-row { @@ -153,7 +165,7 @@ select { } .summary-row code { - max-width: 180px; + max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -209,3 +221,15 @@ select { .device-state-unknown { color: #f8e2a0; } + +.wallet-pubkey { + display: block; + width: 100%; + padding: 10px 12px; + border: 1px solid #314459; + border-radius: 8px; + background: #0d141d; + color: #bed5f5; + white-space: normal; + line-break: anywhere; +} diff --git a/SHiNE-browser-plugin-wallet/popup.html b/SHiNE-browser-plugin-wallet/popup.html index dcd8b98..2e37ae4 100644 --- a/SHiNE-browser-plugin-wallet/popup.html +++ b/SHiNE-browser-plugin-wallet/popup.html @@ -6,7 +6,7 @@ SHiNE Wallet - +
@@ -14,49 +14,14 @@

SHiNE Wallet

Session-only wallet plugin

- offline + не подключено

Сервер SHiNE: —

Адрес: —

- - - - -
-
Войти через другое устройство
+
+
Подключение
- -

- Wallet plugin создаёт временный requester keypair, ждёт подтверждение на доверенном устройстве - и получает только wallet-session без передачи постоянных ключей. -

+
+ + + + + +
- + diff --git a/SHiNE-browser-plugin-wallet/popup.js b/SHiNE-browser-plugin-wallet/popup.js index 170d420..5f55508 100644 --- a/SHiNE-browser-plugin-wallet/popup.js +++ b/SHiNE-browser-plugin-wallet/popup.js @@ -8,6 +8,7 @@ const els = { passwordField: document.querySelector('#password-field'), passwordInput: document.querySelector('#password-input'), startBtn: document.querySelector('#start-btn'), + connectCard: document.querySelector('#connect-card'), pairingCard: document.querySelector('#pairing-card'), shortCode: document.querySelector('#short-code'), pairingHint: document.querySelector('#pairing-hint'), @@ -22,11 +23,15 @@ const els = { resumeBtn: document.querySelector('#resume-btn'), refreshDevicesBtn: document.querySelector('#refresh-devices-btn'), disconnectBtn: document.querySelector('#disconnect-btn'), - signingCard: document.querySelector('#signing-card'), - signKeySelect: document.querySelector('#sign-key-select'), + walletCard: document.querySelector('#wallet-card'), deviceSelect: document.querySelector('#device-select'), homeserverList: document.querySelector('#homeserver-list'), - prepareSignBtn: document.querySelector('#prepare-sign-btn'), + requestWalletBtn: document.querySelector('#request-wallet-btn'), + walletResultCard: document.querySelector('#wallet-result-card'), + walletType: document.querySelector('#wallet-type'), + walletPubkey: document.querySelector('#wallet-pubkey'), + walletVerify: document.querySelector('#wallet-verify'), + copyWalletBtn: document.querySelector('#copy-wallet-btn'), connectionPill: document.querySelector('#connection-pill'), }; @@ -34,16 +39,19 @@ let state = { settings: { serverLogin: 'shineupme', serverHttp: 'https://shineup.me', - serverUrl: 'wss://shineup.me/ws', login: '', }, pairing: { active: false, - pairingId: '', expiresAtMs: 0, + shortCode: '', }, session: null, - connectionOnline: false, + walletProfile: null, + signing: { + selectedDeviceName: '', + }, + currentWallet: null, status: { text: '', kind: 'info', @@ -60,7 +68,7 @@ function setStatus(message, kind = 'info') { } function setConnectedPill(connected) { - els.connectionPill.textContent = connected ? 'online' : 'offline'; + els.connectionPill.textContent = connected ? 'подключено' : 'не подключено'; els.connectionPill.className = connected ? 'pill pill-online' : 'pill pill-offline'; } @@ -104,76 +112,65 @@ function applyState(nextState) { const loginValue = String(state?.settings?.login || ''); const resolvedServerLogin = String(state?.settings?.serverLogin || '').trim(); const resolvedServerAddress = String(state?.settings?.serverHttp || '').trim(); - if (loginValue && resolvedServerLogin && resolvedServerAddress) { - els.serverLoginInfo.textContent = `Сервер SHiNE: ${resolvedServerLogin}`; - els.serverAddress.textContent = `Адрес: ${resolvedServerAddress}`; - } else { - els.serverLoginInfo.textContent = 'Сервер SHiNE: —'; - els.serverAddress.textContent = 'Адрес: —'; - } + els.serverLoginInfo.textContent = resolvedServerLogin ? `Сервер SHiNE: ${resolvedServerLogin}` : 'Сервер SHiNE: —'; + els.serverAddress.textContent = resolvedServerAddress ? `Адрес: ${resolvedServerAddress}` : 'Адрес: —'; if (document.activeElement !== els.loginInput) { els.loginInput.value = loginValue; } - setConnectedPill(!!state?.connectionOnline); + + setConnectedPill(!!state?.session); setStatus(state?.status?.text || '', state?.status?.kind || 'info'); const session = state?.session; const walletProfile = state?.walletProfile; const signing = state?.signing || {}; + const currentWallet = state?.currentWallet || null; + + els.connectCard.classList.toggle('hidden', !!session); + els.sessionCard.classList.toggle('hidden', !session); + els.walletCard.classList.toggle('hidden', !session); + if (session) { - els.sessionCard.classList.remove('hidden'); els.sessionLogin.textContent = session.login || '—'; els.sessionId.textContent = session.sessionId || '—'; els.sessionType.textContent = String(session.sessionType || 50) === '50' ? 'wallet' : String(session.sessionType || '—'); els.deviceKeyShort.textContent = shortKey(walletProfile?.publicKeys?.deviceKeyBase58 || ''); - els.signingCard.classList.remove('hidden'); - } else { - els.sessionCard.classList.add('hidden'); - els.sessionLogin.textContent = '—'; - els.sessionId.textContent = '—'; - els.sessionType.textContent = 'wallet'; - els.deviceKeyShort.textContent = '—'; - els.signingCard.classList.add('hidden'); } - const signKeyOptions = Array.isArray(walletProfile?.signingKeyOptions) ? walletProfile.signingKeyOptions : []; - els.signKeySelect.innerHTML = ''; - signKeyOptions.forEach((item) => { - const option = document.createElement('option'); - option.value = item.id; - option.textContent = item.label; - option.selected = item.id === signing.selectedKeyId; - els.signKeySelect.append(option); - }); - const homeservers = Array.isArray(walletProfile?.homeserverSessions) ? walletProfile.homeserverSessions : []; els.deviceSelect.innerHTML = ''; homeservers.forEach((item) => { const option = document.createElement('option'); option.value = item.sessionName; - option.textContent = `${item.sessionName} [${item.onlineState || 'unknown'}]`; + option.textContent = `${item.sessionName} [${item.onlineState || 'offline'}]`; option.selected = item.sessionName === signing.selectedDeviceName; els.deviceSelect.append(option); }); renderHomeserverList(homeservers); - els.prepareSignBtn.disabled = !session || !signing.selectedKeyId || !signing.selectedDeviceName; + els.requestWalletBtn.disabled = !session || !signing.selectedDeviceName; + + if (currentWallet?.publicKeyBase58) { + els.walletResultCard.classList.remove('hidden'); + els.walletType.textContent = currentWallet.type || '—'; + els.walletPubkey.textContent = currentWallet.publicKeyBase58 || '—'; + els.walletVerify.textContent = currentWallet.verificationText || '—'; + } else { + els.walletResultCard.classList.add('hidden'); + els.walletType.textContent = '—'; + els.walletPubkey.textContent = '—'; + els.walletVerify.textContent = '—'; + } const pairing = state?.pairing || {}; if (pairing.active) { els.pairingCard.classList.remove('hidden'); - const shortCode = String(pairing.shortCode || els.shortCode.dataset.shortCode || els.shortCode.textContent || ''); - els.shortCode.dataset.shortCode = shortCode; - els.shortCode.textContent = formatPairingShortCode(shortCode); - els.pairingHint.textContent = pairing.trustedSessionOnline - ? 'Покажите код на доверенном устройстве и подтвердите выпуск wallet-session.' - : 'Сейчас нет онлайн доверенной сессии. Откройте другое устройство и подтвердите заявку.'; + els.shortCode.textContent = formatPairingShortCode(String(pairing.shortCode || '')); const leftMs = Number(pairing.expiresAtMs || 0) - Date.now(); els.pairingExpire.textContent = leftMs > 0 ? `Код действителен ещё ${formatRemaining(leftMs)}.` : 'Время ожидания истекло.'; els.startBtn.disabled = true; } else { els.pairingCard.classList.add('hidden'); els.shortCode.textContent = formatPairingShortCode(''); - delete els.shortCode.dataset.shortCode; els.pairingExpire.textContent = ''; els.startBtn.disabled = false; } @@ -241,16 +238,13 @@ async function startPairing() { return; } setStatus('Создаём wallet-session заявку...', 'info'); - els.startBtn.disabled = true; try { - const response = await sendMessage('wallet:startPairing', { + await sendMessage('wallet:startPairing', { login, usePassword: !!els.usePassword.checked, password: String(els.passwordInput.value || ''), }); - applyState(response.state); } catch (error) { - els.startBtn.disabled = false; setStatus(error.message || 'Не удалось начать pairing.', 'error'); } } @@ -289,23 +283,33 @@ async function refreshDevices() { } } -async function updateSigningSelection() { +async function updateDeviceSelection() { try { await sendMessage('wallet:updateSigningSelection', { - selectedKeyId: String(els.signKeySelect.value || '').trim(), selectedDeviceName: String(els.deviceSelect.value || '').trim(), }); } catch (error) { - setStatus(error.message || 'Не удалось обновить выбор для подписи.', 'error'); + setStatus(error.message || 'Не удалось обновить выбор homeserver.', 'error'); } } -async function prepareSignSignal() { - setStatus('Готовим каркас запроса подписи...', 'info'); +async function requestCurrentWallet() { + setStatus('Запрашиваем текущий кошелёк с ESP32...', 'info'); try { - await sendMessage('wallet:prepareSignSignal'); + await sendMessage('wallet:requestCurrentWallet'); } catch (error) { - setStatus(error.message || 'Не удалось подготовить запрос подписи.', 'error'); + setStatus(error.message || 'Не удалось получить кошелёк с ESP32.', 'error'); + } +} + +async function copyWalletKey() { + const value = String(els.walletPubkey.textContent || '').trim(); + if (!value || value === '—') return; + try { + await navigator.clipboard.writeText(value); + setStatus('Публичный ключ скопирован.', 'info'); + } catch (error) { + setStatus(error.message || 'Не удалось скопировать ключ.', 'error'); } } @@ -340,9 +344,9 @@ function bindUi() { els.resumeBtn.addEventListener('click', () => { void resumeSession(); }); els.refreshDevicesBtn.addEventListener('click', () => { void refreshDevices(); }); els.disconnectBtn.addEventListener('click', () => { void disconnectSession(); }); - els.signKeySelect.addEventListener('change', () => { void updateSigningSelection(); }); - els.deviceSelect.addEventListener('change', () => { void updateSigningSelection(); }); - els.prepareSignBtn.addEventListener('click', () => { void prepareSignSignal(); }); + els.deviceSelect.addEventListener('change', () => { void updateDeviceSelection(); }); + els.requestWalletBtn.addEventListener('click', () => { void requestCurrentWallet(); }); + els.copyWalletBtn.addEventListener('click', () => { void copyWalletKey(); }); } async function init() { diff --git a/SHiNE-browser-plugin-wallet/provider-bridge.js b/SHiNE-browser-plugin-wallet/provider-bridge.js new file mode 100644 index 0000000..ff6c2a7 --- /dev/null +++ b/SHiNE-browser-plugin-wallet/provider-bridge.js @@ -0,0 +1,192 @@ +import { PublicKey, Transaction, VersionedTransaction } from './js/lib/vendor/solana-publickey-bundle.js'; + +const PAGE_REQUEST = 'shine-wallet-page-request'; +const PAGE_RESPONSE = 'shine-wallet-page-response'; + +function bytesToBase64(bytes) { + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + const slice = bytes.subarray(i, i + chunk); + binary += String.fromCharCode(...slice); + } + return btoa(binary); +} + +function base64ToBytes(value) { + const binary = atob(String(value || '').trim()); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + out[i] = binary.charCodeAt(i); + } + return out; +} + +function createProviderError(message, code = '') { + const error = new Error(String(message || 'Wallet provider error')); + if (code === 'USER_REJECTED' || code === 'NOT_TRUSTED') { + error.code = 4001; + } else if (code) { + error.code = code; + } + return error; +} + +function createRequest(method, params = {}) { + const id = `shine-wallet-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + return new Promise((resolve, reject) => { + const onMessage = (event) => { + if (event.source !== window) return; + const data = event.data || {}; + if (data?.target !== PAGE_RESPONSE || String(data?.id || '') !== id) return; + window.removeEventListener('message', onMessage); + if (!data?.ok) { + reject(createProviderError(data?.error || 'Wallet request failed', String(data?.code || ''))); + return; + } + resolve(data?.result || {}); + }; + window.addEventListener('message', onMessage); + window.postMessage({ + target: PAGE_REQUEST, + id, + method, + params, + }, window.location.origin); + }); +} + +function serializeTransactionBase64(transaction) { + if (!transaction || typeof transaction.serialize !== 'function') { + throw createProviderError('Unsupported transaction object', 'UNSUPPORTED_TRANSACTION'); + } + let raw; + try { + raw = transaction.serialize({ requireAllSignatures: false, verifySignatures: false }); + } catch { + raw = transaction.serialize(); + } + const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw); + return bytesToBase64(bytes); +} + +function deserializeSignedTransaction(base64, originalTransaction) { + const bytes = base64ToBytes(base64); + const ctor = originalTransaction?.constructor; + if (ctor && typeof ctor.deserialize === 'function') { + return ctor.deserialize(bytes); + } + if (ctor && typeof ctor.from === 'function') { + return ctor.from(bytes); + } + if (typeof originalTransaction?.version === 'number') { + return VersionedTransaction.deserialize(bytes); + } + return Transaction.from(bytes); +} + +class ShineSolanaProvider { + constructor() { + this.isSHiNE = true; + this.isPhantom = true; + this.publicKey = null; + this.isConnected = false; + this._listeners = new Map(); + } + + on(event, handler) { + const key = String(event || ''); + if (!this._listeners.has(key)) { + this._listeners.set(key, new Set()); + } + this._listeners.get(key).add(handler); + return this; + } + + off(event, handler) { + const bucket = this._listeners.get(String(event || '')); + if (!bucket) return this; + bucket.delete(handler); + if (!bucket.size) { + this._listeners.delete(String(event || '')); + } + return this; + } + + removeListener(event, handler) { + return this.off(event, handler); + } + + emit(event, payload) { + const bucket = this._listeners.get(String(event || '')); + if (!bucket?.size) return; + for (const handler of [...bucket]) { + try { + handler(payload); + } catch {} + } + } + + async connect(options = {}) { + const onlyIfTrusted = !!options?.onlyIfTrusted; + if (!onlyIfTrusted) { + const confirmed = window.confirm(`Connect SHiNE Wallet to ${window.location.origin}?`); + if (!confirmed) { + throw createProviderError('User rejected wallet connection', 'USER_REJECTED'); + } + } + const result = await createRequest('connect', { onlyIfTrusted }); + const nextKey = new PublicKey(String(result?.publicKeyBase58 || '').trim()); + this.publicKey = nextKey; + this.isConnected = true; + this.emit('connect', nextKey); + this.emit('accountChanged', nextKey); + return { publicKey: nextKey }; + } + + async disconnect() { + await createRequest('disconnect', {}); + this.isConnected = false; + this.publicKey = null; + this.emit('disconnect'); + this.emit('accountChanged', null); + } + + async signTransaction(transaction) { + if (!this.publicKey) { + await this.connect(); + } + const transactionBase64 = serializeTransactionBase64(transaction); + const comment = `Site ${window.location.origin} requested transaction signature`; + const result = await createRequest('signTransaction', { + publicKeyBase58: this.publicKey?.toBase58?.() || '', + transactionBase64, + comment, + }); + return deserializeSignedTransaction(String(result?.signedTransactionBase64 || ''), transaction); + } + + async request(args = {}) { + const method = String(args?.method || ''); + const params = args?.params; + if (method === 'connect') { + return this.connect(Array.isArray(params) ? params[0] : params || {}); + } + if (method === 'disconnect') { + return this.disconnect(); + } + if (method === 'signTransaction') { + const tx = Array.isArray(params) ? params[0] : params?.transaction || params; + return this.signTransaction(tx); + } + throw createProviderError(`Unsupported request method: ${method}`, 'UNSUPPORTED_METHOD'); + } +} + +if (!window.solana) { + const provider = new ShineSolanaProvider(); + window.solana = provider; + window.phantom = window.phantom || {}; + window.phantom.solana = provider; + window.dispatchEvent(new Event('solana#initialized')); +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java index 38dc3fb..bf08799 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java @@ -74,7 +74,8 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler { long now = System.currentTimeMillis(); EspPairingRequestsDAO.getInstance().expirePending(now); - if (settings.getBlockedUntilMs() > now) { + long blockedUntilMs = settings == null ? 0L : settings.getBlockedUntilMs(); + if (blockedUntilMs > now) { return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Временная блокировка pairing по числу неудачных попыток"); } int recentAttempts = EspPairingRequestsDAO.getInstance().countRecentByLoginAndStatuses( diff --git a/VERSION.properties b/VERSION.properties index 3ad2a6e..6d72a9e 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.230 -server.version=1.2.216 +client.version=1.2.231 +server.version=1.2.217