From cf6a2830c8761b6d9772f987b6c2fb63dd4107405d9755dca5aab6b4bc4defa1 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 11 Jun 2026 04:10:31 +0400 Subject: [PATCH] =?UTF-8?q?solana:=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D1=8C=20griefing=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20PDA=20=D0=B8=20=D0=B7=D0=B0=D0=BC=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B7=D0=BA=D1=83=20=D0=B2=D1=8B=D0=BF=D0=BB=D0=B0=D1=82,=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B0=D1=83?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=20=E2=84=962?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shine_payments + shine_users: - create_pda_account переведён на «создание поверх предзаполненного» (allocate+assign+добор ренты), чтобы подсев лампортов на детерминированный адрес PDA (тикет/логин) не блокировал создание — закрыт LOW из аудита №1; в shine_payments is_uninitialized_account перестала зависеть от баланса. shine_payments (HIGH из аудита №2): - запрещён recipient == inflow_vault в buy_ticket*, manager_add_ticket и change_ticket_recipient; добавлена защита по умолчанию в transfer_from_vault (require vault.key != recipient.key). Это убирает алиасинг аккаунта в step_payout, который навсегда замораживал очередь выплат и средства вольта. Документация и учёт: - doc/programs/shine_payments.md §3.4, §10.1; doc/programs/shine_users.md §3.3; - Dev_Docs/audit: добавлен аудит №2, обе закрытые находки помечены ИСПРАВЛЕНО; - Dev_Docs/Pending_Features: две записи на ручную e2e-проверку на devnet; - VERSION.properties: client 1.2.161, server 1.2.150. Co-Authored-By: Claude Fable 5 --- ...2026-06-11_payments-recipient-not-vault.md | 33 ++++ .../2026-06-11_pda-anti-griefing-create.md | 32 ++++ .../Solana-audit-2-by-Claude-11июня2026.md | 177 ++++++++++++++++++ .../Solana-audit-by-Claude-File5-9июня2026.md | 14 +- VERSION.properties | 4 +- .../shine/doc/programs/shine_payments.md | 39 +++- .../shine/doc/programs/shine_users.md | 17 ++ .../shine/programs/shine_payments/src/lib.rs | 59 +++++- .../shine/programs/shine_users/src/lib.rs | 30 ++- 9 files changed, 389 insertions(+), 16 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-11_payments-recipient-not-vault.md create mode 100644 Dev_Docs/Pending_Features/2026-06-11_pda-anti-griefing-create.md create mode 100644 Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md diff --git a/Dev_Docs/Pending_Features/2026-06-11_payments-recipient-not-vault.md b/Dev_Docs/Pending_Features/2026-06-11_payments-recipient-not-vault.md new file mode 100644 index 0000000..caaf849 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-11_payments-recipient-not-vault.md @@ -0,0 +1,33 @@ +# Запрет получателя тикета, равного inflow-вольту (защита от заморозки очереди) + +## Краткое описание +Закрыта HIGH-находка второго аудита (`Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md`). +Тикет с `recipient_wallet == адрес inflow_vault` приводил к алиасингу аккаунта в +`step_payout` (вольт одновременно источник и получатель), второй mutable-займ +лампортов в `transfer_from_vault` падал, и такой тикет навсегда блокировал +обслуживание очереди и замораживал средства вольта. + +Исправление в `shine_payments`: +- `buy_ticket_by_purchase_usd` — `require!(recipient_wallet != config.inflow_vault)`; +- `process_manager_add_ticket` — запрет через `find_single_pda(INFLOW_VAULT_SEED)`; +- `process_change_ticket_recipient` — тот же запрет для `new_recipient_wallet`; +- `transfer_from_vault` — защита по умолчанию `require!(vault.key != recipient.key)`. + +Ошибка во всех случаях — `InvalidTicketRecipient`. + +## Что проверять (devnet/localnet) +1. Покупка тикета с `recipient_wallet = <адрес inflow_vault PDA>` → отклоняется + (`InvalidTicketRecipient`), тикет не создаётся. +2. `manager_add_ticket` с тем же recipient → отклоняется. +3. `change_ticket_recipient` с `new_recipient = inflow_vault` → отклоняется. +4. Обычные покупки/выплаты с нормальным получателем → работают как раньше, очередь + обслуживается, `step_payout` выплачивает корректно. +5. Регресс не затронул выплаты в `dao_wallet` и `call_reward` подписанту (их адреса + не совпадают с вольтом). + +## Ожидаемый результат +Тикет с получателем-вольтом невозможно создать; ранее существовавший вектор +перманентной заморозки очереди закрыт. Прочая логика выплат без изменений. + +## Статус +pending diff --git a/Dev_Docs/Pending_Features/2026-06-11_pda-anti-griefing-create.md b/Dev_Docs/Pending_Features/2026-06-11_pda-anti-griefing-create.md new file mode 100644 index 0000000..247f4ec --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-11_pda-anti-griefing-create.md @@ -0,0 +1,32 @@ +# Устойчивое создание PDA (защита от «минирования» адреса) + +## Краткое описание +В `shine_payments` и `shine_users` функция `create_pda_account` переведена с жёсткого +`system_instruction::create_account` на паттерн «создание поверх предзаполненного»: +- если на детерминированном адресе будущего PDA нет лампортов — обычный `create_account`; +- если лампорты уже есть («подсев» атакующим) — добор ренты переводом, затем + `allocate` + `assign` под подписью PDA. + +Дополнительно в `shine_payments` `is_uninitialized_account` перестала требовать нулевой +баланс (проверяет только пустые данные + владельца System Program). + +Это закрывает последний (LOW) пункт аудита: griefing-DoS на покупку тикетов и сквоттинг +логинов через предсказуемые адреса PDA. + +## Что проверять (devnet/localnet) +1. Обычная покупка тикета без «подсева» — создаётся как раньше (быстрый путь). +2. На адрес следующего тикета заранее переведены лампорты обычным system-переводом → + покупка/`manager_add_ticket` всё равно проходит, тикет создаётся корректно, данные + и владелец = program id. +3. На адрес `user_pda` будущего логина заранее переведены лампорты → регистрация логина + (`create_user_pda`) всё равно проходит; запись и комиссия корректны. +4. Повторная инициализация уже существующего тикета/пользователя по-прежнему отклоняется + (`PdaAlreadyExists` / `UserAlreadyExists`). +5. Singleton-PDA (`init`, economy config) и manager allowance создаются без регрессий. + +## Ожидаемый результат +Подсев лампортов на заранее известный адрес PDA не блокирует создание; вся остальная +экономика и проверки повторной инициализации работают как прежде. + +## Статус +pending diff --git a/Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md b/Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md new file mode 100644 index 0000000..efb4406 --- /dev/null +++ b/Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md @@ -0,0 +1,177 @@ +# Аудит безопасности Solana-программ SHiNE — выпуск 2 (11.06.2026) + +Повторный независимый аудит после исправления всех 4 находок первого отчёта +(`Solana-audit-by-Claude-File5-9июня2026.md`). Код перечитан целиком: + +- `shine_login_guard` (183 строки) — stateless-классификатор логинов; +- `shine_users` (1069 строк) — реестр пользователей, PDA-записи, подписи, экономика лимитов; +- `shine_payments` (1381 строка) — очереди тикетов, выплаты из вольта, оракул Pyth. + +Перебраны классы атак: подмена аккаунтов/PDA, авторизация и подписи, арифметика и +переполнения, валидация оракула, экономика, реентранси, griefing/DoS, **алиасинг +аккаунтов (передача одного аккаунта в несколько слотов инструкции)**. + +## Статус прошлых находок (все закрыты) + +- 🔴 Critical #1 (economy-config PDA в `shine_users`) — закрыто: `validate_users_economy_config_pda` проверяет и адрес, и `owner == program_id`, и вызывается перед чтением и в create, и в update. +- 🔴 Critical #2 (singleton-PDA в `shine_payments`) — закрыто: `validate_singleton_state_pda` проверяет точный адрес + `owner == id()` во всех инструкциях (`update_coef_limit`, `grant_manager_limits`, `buy_ticket*`, `manager_add_ticket`, `step_payout`, `change_ticket_recipient`). +- 🟠 Medium (валидация Pyth) — закрыто: пин адреса аккаунта `PYTH_SOL_USD_ACCOUNT`, проверка `owner == pyth_receiver`, разбор официальным `PriceUpdateV2`, `get_price_no_older_than` с проверкой `feed_id`, проверка возраста и доверительного интервала (`ORACLE_MAX_CONFIDENCE_PPM`). +- 🟡 Low (griefing на предсказуемых адресах) — закрыто: `create_pda_account` в обеих программах переведён на «создание поверх предзаполненного» (allocate + assign + добор ренты). + +--- + +## 🔴 HIGH (НОВОЕ) — `shine_payments`: тикет с `recipient_wallet == inflow_vault` навсегда замораживает все выплаты — ✅ ИСПРАВЛЕНО (11.06.2026) + +Закрыто: равенство `recipient == inflow_vault` запрещено во всех точках задания +получателя — `buy_ticket_by_purchase_usd` (через `config.inflow_vault`), +`process_manager_add_ticket` и `process_change_ticket_recipient` (через +`find_single_pda(INFLOW_VAULT_SEED)`). Дополнительно в `transfer_from_vault` добавлена +защита по умолчанию `require!(vault.key != recipient.key)`. Документация — +`doc/programs/shine_payments.md` §10.1. Историческое описание находки ниже. + + + +### Где +`transfer_from_vault` (строки 1258–1268) переводит лампорты из вольта прямой +манипуляцией балансами (вольт — PDA без приватного ключа, обычный system-перевод +невозможен): + +```rust +fn transfer_from_vault(vault: &AccountInfo, recipient: &AccountInfo, amount: u64) -> ProgramResult { + if amount == 0 { return Ok(()); } + let mut vault_lamports = vault.try_borrow_mut_lamports()?; // займ #1 + let mut recipient_lamports = recipient.try_borrow_mut_lamports()?; // займ #2 + ... +} +``` + +В `step_payout` (строка 849) получатель — это `ticket.recipient_wallet`: + +```rust +transfer_from_vault(inflow_vault_pda, ticket_recipient_wallet, ticket_lamports)?; +``` + +А `recipient_wallet` нигде не валидируется при создании тикета: +`buy_ticket*` (строки 696/711/725 → 1031), `manager_add_ticket` (строка 765), +`change_ticket_recipient` (строка 900) — берут его «как есть» из аргументов. + +### Суть атаки (алиасинг аккаунта) +В Solana, если один и тот же аккаунт передан в инструкцию в нескольких слотах, +рантайм отдаёт для всех слотов **один и тот же** `RefCell` (механизм дублей). +Поэтому если `ticket.recipient_wallet` равен адресу `inflow_vault` PDA, то в +`step_payout` аккаунт вольта попадает и в слот `inflow_vault_pda`, и в слот +`ticket_recipient_wallet`. Тогда внутри `transfer_from_vault`: + +- `vault.try_borrow_mut_lamports()` — берёт mutable-займ (успех); +- `recipient.try_borrow_mut_lamports()` — это **тот же** аккаунт → второй + mutable-займ → `Err(AccountBorrowFailed)` → `?` возвращает ошибку → инструкция + падает. + +### Почему это «заморозка всего», а не один тикет +Выплаты идут строго по возрастанию индекса. `step_payout` всегда обслуживает +сначала очередь Q1 (если в ней есть pending), затем Q2, затем Q3, и в каждой — +ровно «следующий неоплаченный» тикет (`paid + 1`). Тикет с `recipient == vault`: + +- не может быть оплачен (`step_payout` всегда падает на нём); +- не может быть пропущен (нет механизма «skip»); +- блокирует все тикеты после него в своей очереди; +- если он в Q1 — блокирует обслуживание Q2 и Q3 (до них очередь не доходит); +- лампорты вольта (накопленные регистрационные комиссии) перестают выплачиваться + и не уходят в DAO (слив в DAO происходит только когда `pending == 0` по всем + очередям, а это состояние недостижимо). + +### Эксплуатация (тривиальная, перестановочная) +Q1 — публичная очередь (`buy_ticket` доступен любому). Атакующий покупает **один** +дешёвый тикет Q1, указав `recipient_wallet = <адрес inflow_vault PDA>`. Адрес вольта +детерминирован и публичен (`find_single_pda(INFLOW_VAULT_SEED)`). С этого момента вся +подсистема выплат и средства вольта заморожены за стоимость одного тикета + ренты. + +Дополнительно: даже при защите на этапе покупки остаётся вектор через +`change_ticket_recipient` (строка 900) — владелец любого своего неоплаченного тикета +может выставить `new_recipient_wallet = vault` позже. + +### Класс и серьёзность +Класс: «account aliasing / duplicate-account mutable borrow» + отсутствие +валидации адреса получателя. Прямой кражи средств нет, но это перманентный +отказ в обслуживании (availability) с блокировкой средств вольта, триггер — +копеечный и доступен анонимно. Оценка: **HIGH**. + +### Рекомендуемый фикс +Запретить `recipient`, равный адресу вольта, во всех точках, где он задаётся, чтобы +тикет с таким получателем вообще не мог появиться: + +1. в `buy_ticket_by_purchase_usd` — `require!(recipient_wallet != config.inflow_vault, …)` + (config уже прочитан); +2. в `process_manager_add_ticket` — сверять с `find_single_pda(INFLOW_VAULT_SEED).0`; +3. в `process_change_ticket_recipient` — то же для `new_recipient_wallet`. + +Дополнительно (defense-in-depth) — в `transfer_from_vault` явно +`require!(vault.key != recipient.key, …)` с понятной ошибкой, чтобы любой будущий +вызов был защищён от алиасинга. Этого `require` недостаточно как единственной меры +(тикет всё равно застрял бы), поэтому основная защита — на входе. + +--- + +## 🟡 LOW / INFO — наблюдения без прямой эксплуатации + +### L1. `change_ticket_recipient` и `buy_ticket` не проверяют получателя на «опасные» адреса +Связано с HIGH выше; после фикса основной проблемы стоит заодно зафиксировать +правило «получатель не должен совпадать с системными PDA программы». + +### L2. Гонка за логином (first-come) в `shine_users` +Адрес `user_pda` выводится из логина. После закрытия griefing-подсева остаётся +обычное состязание: увидев в мемпуле регистрацию `alice`, атакующий может +зарегистрировать `alice` со своим `root_key` первым. On-chain это решается только +commit-reveal; для текущей модели — приемлемый риск, отметить как известный. + +### L3. `step_payout` без slippage-параметра +Выплата считается по текущей цене оракула без верхней границы лампортов. Цена +ограничена возрастом (120с) и доверительным интервалом (10%), аккаунт оракула +запинен — манипуляция маловероятна, но при резком движении цены SOL объём выплаты +в лампортах плавает. Риск низкий; при желании добавить верхнюю границу на шаг. + +### L4. Экономическая устойчивость вольта (дизайн, не баг) +Деньги за покупку тикетов (`buy_ticket`) уходят на `dao_wallet`, а выплаты в +`step_payout` идут из `inflow_vault`, который наполняется **регистрационными +комиссиями** `shine_users`. Если поток регистраций меньше обязательств по выплатам, +вольт истощается и выплаты останавливаются (без потери средств, но с остановкой +сервиса). Это свойство экономической модели — стоит явно держать в уме и +мониторить баланс вольта/обязательств. + +### L5. Заполнение Q1 до лимита как мягкий DoS +`buy_ticket` блокируется при `q1_sum_total >= limit_usd_cents`. Атакующий может +наполнить Q1 своими тикетами и приостановить покупки. Дорого (тратит SOL в DAO и +ренту) и его же тикеты потом оплачиваются из вольта, поэтому это скорее +экономический, а не дешёвый griefing. Риск низкий. + +--- + +## ✅ Проверено и подтверждено как корректное + +- **Подмена singleton-PDA** невозможна: везде сверяется точный адрес и владелец. +- **Авторизация**: `update_coef_limit`/`grant_manager_limits` требуют `signer == config.dao_wallet`; `manager_add_ticket` — `signer == allowance.manager_wallet`; `change_ticket_recipient` — `signer == ticket.recipient_wallet`; обновление economy-config — `signer == DAO_AUTHORITY`. +- **Ed25519 в `shine_users`**: строгие относительные индексы (−1/−2), `num_signatures == 1`, все три `ix_index == u16::MAX` (данные внутри самой ed25519-инструкции), сверка pubkey/signature/message по хэшу. Подмена и указание на чужую инструкцию исключены. +- **Цепочка версий записи** (`version == record_number+1`, `prev_hash == hash(old)`) — корректная защита от replay; сигнатура записи завязана на `root_key`, а не на плательщика. +- **Монотонность** `used_bytes`/`last_block_number` и `used_bytes <= paid_limit_bytes`. +- **Арифметика**: повсеместные `checked_*`, `overflow-checks = true`, расчёты оракула в `u128` с `u64::try_from` на сужении. +- **Оракул Pyth**: пин аккаунта + owner + feed_id + возраст + confidence через официальный SDK. +- **Рент-экземпт вольта** сохраняется: `available_vault_lamports` вычитает `minimum_balance`, а суммарная проверка `available >= needed` гарантирует, что после выплат вольт не опустится ниже ренты. +- **Двойная оплата тикета** исключена: `is_paid` + инкремент `*_tickets_paid`, следующий шаг адресует следующий индекс. +- **Реентранси отсутствует**: CPI только в System Program (transfer/allocate/assign) и в stateless `shine_login_guard` (с проверкой возвращённого `program_id`); обратных вызовов в наши программы нет. +- **create_pda_account (новый)**: устойчив к подсеву лампортов; атакующий не может ни выделить данные, ни сменить владельца PDA (нет ключа/seeds), поэтому ветка allocate+assign безопасна. +- **shine_login_guard**: stateless, без аккаунтов и средств; DFS-классификация ограничена (`MAX_WORDS_PER_LOGIN = 3`, длина ≤ 20) — без compute-DoS. + +--- + +## Приоритет действий + +1. **HIGH** — запретить `recipient == inflow_vault` в `buy_ticket*`, `manager_add_ticket`, + `change_ticket_recipient`; добавить `require!(vault.key != recipient.key)` в + `transfer_from_vault` как защиту по умолчанию. Закрыть до mainnet. +2. **LOW** — зафиксировать правило «получатель ≠ системные PDA» (L1), оценить + добавление верхней границы выплаты на шаг (L3). +3. **INFO** — формально задокументировать экономику вольта (L4) и known-issue + гонки за логином (L5/L2). + +Изменений в код в рамках этого аудита не вносил — это анализ. Готов подготовить патч +по пункту 1, если подтвердите. diff --git a/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md b/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md index 82b280f..3cf5ca9 100644 --- a/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md +++ b/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md @@ -73,7 +73,19 @@ read_sol_usd_price / parse_pyth_price_update_v2 (строки 1038–1075): Проверка возраста цены (ORACLE_MAX_AGE_SECS = 120) есть и сделана корректно. Рекомендация: проверять владельца аккаунта, сверять feed_id с константой и валидировать verification_level == Full (или парсить через официальный pyth_solana_receiver_sdk, который уже завендорен в .vendor/). --- -🟡 LOW — DoS через предсказуемые адреса тикетов +🟡 LOW — DoS через предсказуемые адреса тикетов — ✅ ИСПРАВЛЕНО (11.06.2026) + +Закрыто: `create_pda_account` в `shine_payments` и `shine_users` переведён на паттерн +«создание поверх предзаполненного» (allocate + assign + добор ренты вместо строгого +`system_instruction::create_account`). «Подсев» лампортов на заранее известный адрес +тикета или пользовательской записи больше не блокирует создание PDA. Проверка +`is_uninitialized_account` в payments перестала зависеть от нулевого баланса. Тот же фикс +закрывает аналогичный сквоттинг логинов в `shine_users` (адрес выводится из логина). +Подробности — в `doc/programs/shine_payments.md` §3.4 и `doc/programs/shine_users.md` §3.3. + +Историческое описание находки ниже. + + is_uninitialized_account (строка 1195) считает аккаунт неинициализированным только если lamports() == 0. Адреса тикетов детерминированы (queue_seed + index), а индекс последователен и предсказуем. Любой может заранее перевести немного лампортов на адрес следующего тикета — тогда create_pda_account упадёт (PdaAlreadyExists / ошибка create_account), заблокировав покупку/добавление тикета. Это griefing-DoS, не кража. Митигировать можно паттерном «create поверх предзаполненного» (allocate + assign + добор ренты) вместо system_instruction::create_account. diff --git a/VERSION.properties b/VERSION.properties index 003bbd1..38e42da 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.159 -server.version=1.2.148 +client.version=1.2.161 +server.version=1.2.150 diff --git a/shine-solana/shine/doc/programs/shine_payments.md b/shine-solana/shine/doc/programs/shine_payments.md index 3c471a9..607aa0b 100644 --- a/shine-solana/shine/doc/programs/shine_payments.md +++ b/shine-solana/shine/doc/programs/shine_payments.md @@ -58,6 +58,24 @@ - seed prefix: `shine_p_manager_allow` - второй seed: `manager_wallet.as_ref()` +### 3.4. Правило создания PDA (защита от «минирования» адреса) + +Все адреса PDA детерминированы (особенно тикеты: seed + последовательный `ticket_index`), +поэтому злоумышленник может заранее вычислить адрес будущего тикета и перевести на него +немного лампортов обычным system-переводом. Если бы создание шло строго через +`system_instruction::create_account`, такой «подсев» приводил бы к ошибке +«account already in use» и навсегда блокировал бы покупку/добавление тикета (griefing-DoS). + +Поэтому `create_pda_account` создаёт аккаунт устойчиво к предзаполненному балансу: + +- если на адресе нет лампортов — обычный `create_account` (быстрый путь); +- если лампорты уже есть — «создание поверх предзаполненного»: добор ренты переводом, + затем `allocate` + `assign` под подписью PDA. + +Признак «инициализируемого» аккаунта (`is_uninitialized_account`) — пустые данные и +владелец System Program; условие нулевого баланса намеренно не проверяется. Уже созданный +программой PDA имеет данные/владельца = program id и повторно инициализирован не будет. + ## 4. Состояния программы ### 4.1. `ConfigState` @@ -218,7 +236,8 @@ - ticket создаётся только в `Q1`; - очередь `Q1` временно блокируется, если достигнут её суммарный лимит; - `payout_usd_cents = purchase_usd_cents * coef_ppm / COEF_SCALE_PPM`; -- `recipient_wallet` записывается в ticket. +- `recipient_wallet` записывается в ticket; +- `recipient_wallet` **не должен совпадать с адресом inflow-вольта** (см. §10.1). ### 9.2. Важная деталь денежного потока @@ -248,6 +267,7 @@ Signer должен совпадать с `manager_wallet` в `manager_allowance - `queue_id` только `1`, `2` или `3`; - `payout_usd_cents > 0`; +- `recipient_wallet` не равен адресу inflow-вольта (см. §10.1); - доступный allowance по очереди не меньше суммы тикета; - ticket PDA ещё не существует. @@ -257,6 +277,21 @@ Signer должен совпадать с `manager_wallet` в `manager_allowance - allowance уменьшается; - агрегаты соответствующей очереди увеличиваются. +### 10.1. Запрет получателя, равного inflow-вольту (важно для безопасности) + +`recipient_wallet` тикета не должен совпадать с адресом `inflow_vault` PDA. +Причина: в `step_payout` выплата получателю исполняется прямой манипуляцией +балансами (`transfer_from_vault`), где источником служит сам вольт. Если получатель +равен вольту, один и тот же аккаунт попадает в инструкцию дважды (в Solana дубли +делят общий `RefCell`), и второй mutable-займ лампортов завершается ошибкой. Такой +тикет невозможно ни оплатить, ни пропустить — он навсегда блокирует обслуживание +очереди (а если он в `Q1`, то и `Q2`/`Q3`) и замораживает средства вольта. + +Поэтому равенство `recipient == inflow_vault` запрещено во всех точках, где +получатель задаётся: `buy_ticket*`, `manager_add_ticket`, `change_ticket_recipient`. +Дополнительно `transfer_from_vault` содержит защиту по умолчанию +`require!(vault.key != recipient.key)`. + ## 11. Инструкция `step_payout` ### Назначение @@ -334,6 +369,8 @@ next_index = tickets_paid + 1 ### Ограничения +`new_recipient_wallet` не должен совпадать с адресом inflow-вольта (см. §10.1). + Нельзя менять получателя у следующего тикета на выплату в активной очереди. Логика: diff --git a/shine-solana/shine/doc/programs/shine_users.md b/shine-solana/shine/doc/programs/shine_users.md index 57e362a..46a6c3d 100644 --- a/shine-solana/shine/doc/programs/shine_users.md +++ b/shine-solana/shine/doc/programs/shine_users.md @@ -74,6 +74,23 @@ PDA экономических настроек: users_economy_config_pda = PDA(["shine_users_economy_config"], shine_users_program_id) ``` +### 3.3. Правило создания PDA (защита от «минирования» адреса) + +Адрес пользовательской PDA выводится из логина и публично предсказуем: зная желаемый логин, +любой может заранее вычислить адрес записи и перевести на него немного лампортов обычным +system-переводом. Если бы создание шло строго через `system_instruction::create_account`, +такой «подсев» приводил бы к ошибке «account already in use» и навсегда блокировал бы +регистрацию этого логина (targeted-DoS / сквоттинг логинов), причём без оплаты комиссии. + +Поэтому `create_pda_account` создаёт аккаунт устойчиво к предзаполненному балансу: + +- если на адресе нет лампортов — обычный `create_account` (быстрый путь); +- если лампорты уже есть — «создание поверх предзаполненного»: добор ренты переводом, + затем `allocate` + `assign` под подписью PDA. + +Проверки повторной инициализации (`owner == System Program` и пустые данные) остаются и +не зависят от баланса аккаунта. + ## 4. Состояния программы ### 4.1. `UsersEconomyConfigState` diff --git a/shine-solana/shine/programs/shine_payments/src/lib.rs b/shine-solana/shine/programs/shine_payments/src/lib.rs index 056e8ef..481a854 100644 --- a/shine-solana/shine/programs/shine_payments/src/lib.rs +++ b/shine-solana/shine/programs/shine_payments/src/lib.rs @@ -741,6 +741,10 @@ fn process_manager_add_ticket( require!(args.payout_usd_cents > 0, PaymentsError::InvalidPayoutAmount); require!(is_valid_queue_id(args.queue_id), PaymentsError::InvalidTicketQueue); + // Получатель не должен совпадать с inflow-вольтом (см. подробности в + // buy_ticket_by_purchase_usd): иначе тикет навсегда застрянет в step_payout. + let (inflow_vault_addr, _) = find_single_pda(program_id, settings::INFLOW_VAULT_SEED); + require!(args.recipient_wallet != inflow_vault_addr, PaymentsError::InvalidTicketRecipient); let (expected_manager_pda, _) = find_manager_allowance_pda(program_id, signer.key); require_keys_eq!(expected_manager_pda, *manager_allowance_pda.key, PaymentsError::InvalidPdaAddress); @@ -868,6 +872,11 @@ fn process_change_ticket_recipient( require!(account_iter.next().is_none(), PaymentsError::InvalidInstruction); validate_singleton_state_pda(program_id, queues_pda, settings::QUEUES_SEED)?; + // Новый получатель не должен совпадать с inflow-вольтом (см. подробности в + // buy_ticket_by_purchase_usd): иначе тикет навсегда застрянет в step_payout. + let (inflow_vault_addr, _) = find_single_pda(program_id, settings::INFLOW_VAULT_SEED); + require!(args.new_recipient_wallet != inflow_vault_addr, PaymentsError::InvalidTicketRecipient); + let queues = read_state::(queues_pda)?; let mut ticket = read_state::(ticket_pda)?; require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid); @@ -1010,6 +1019,11 @@ fn buy_ticket_by_purchase_usd( let mut queues = read_state::(ctx.queues_pda)?; require_keys_eq!(*ctx.dao_wallet.key, config.dao_wallet, PaymentsError::InvalidDaoWallet); + // Получатель тикета не должен совпадать с inflow-вольтом: иначе в step_payout + // вольт окажется и источником, и получателем перевода (алиасинг одного аккаунта), + // второй mutable-займ в transfer_from_vault упадёт, и такой тикет навсегда + // заморозит обслуживание очереди. Запрещаем такой recipient на входе. + require!(recipient_wallet != config.inflow_vault, PaymentsError::InvalidTicketRecipient); let queue1_sum_total_before = queues.q1_sum_total_usd_cents; require!(queue1_sum_total_before < coef_limit.limit_usd_cents, PaymentsError::QueueTemporarilyPaused); @@ -1192,13 +1206,31 @@ fn create_pda_account<'info>( space: u64, ) -> ProgramResult { require!(is_uninitialized_account(pda), PaymentsError::PdaAlreadyExists); - let lamports = Rent::get()?.minimum_balance(space as usize); - let create_ix = system_instruction::create_account(payer.key, pda.key, lamports, space, program_id); - invoke_signed( - &create_ix, - &[payer.clone(), pda.clone(), system_program_ai.clone()], - &[seeds], - ) + let required_lamports = Rent::get()?.minimum_balance(space as usize); + let current_lamports = pda.lamports(); + + if current_lamports == 0 { + // Быстрый путь: адрес пуст — обычное создание аккаунта одной инструкцией. + let create_ix = system_instruction::create_account(payer.key, pda.key, required_lamports, space, program_id); + return invoke_signed( + &create_ix, + &[payer.clone(), pda.clone(), system_program_ai.clone()], + &[seeds], + ); + } + + // На адресе уже лежат лампорты — вероятно, «подсев» атакующим на заранее + // известный детерминированный адрес тикета/PDA. Создаём «поверх предзаполненного»: + // доводим ренту переводом, затем allocate + assign под подписью PDA. + let top_up = required_lamports.saturating_sub(current_lamports); + if top_up > 0 { + let transfer_ix = system_instruction::transfer(payer.key, pda.key, top_up); + invoke(&transfer_ix, &[payer.clone(), pda.clone(), system_program_ai.clone()])?; + } + let allocate_ix = system_instruction::allocate(pda.key, space); + invoke_signed(&allocate_ix, &[pda.clone(), system_program_ai.clone()], &[seeds])?; + let assign_ix = system_instruction::assign(pda.key, program_id); + invoke_signed(&assign_ix, &[pda.clone(), system_program_ai.clone()], &[seeds]) } fn write_state(pda: &AccountInfo, state: &T) -> ProgramResult { @@ -1219,9 +1251,15 @@ fn read_state(pda: &AccountInfo) -> Result { T::decode(&data[..encoded_len]) } +// «Инициализируемым» считаем аккаунт без данных, всё ещё принадлежащий System +// Program. Условие lamports() == 0 сознательно убрано: адреса тикетов и прочих +// PDA детерминированы, и любой может заранее перевести на них немного лампортов, +// чтобы заблокировать создание (griefing-DoS). Наличие лампортов больше не должно +// мешать инициализации — за безопасное создание «поверх предзаполненного» отвечает +// create_pda_account. Уже созданный нашей программой PDA имеет данные/владельца id() +// и сюда не пройдёт. fn is_uninitialized_account(account: &AccountInfo) -> bool { - account.lamports() == 0 - && account.data_len() == 0 + account.data_len() == 0 && (*account.owner == system_program::ID || *account.owner == Pubkey::default()) } @@ -1235,6 +1273,9 @@ fn transfer_from_vault(vault: &AccountInfo, recipient: &AccountInfo, amount: u64 if amount == 0 { return Ok(()); } + // Защита по умолчанию от алиасинга: источник и получатель не должны быть одним + // и тем же аккаунтом, иначе второй mutable-займ лампортов ниже завершится ошибкой. + require!(vault.key != recipient.key, PaymentsError::InvalidTicketRecipient); let mut vault_lamports = vault.try_borrow_mut_lamports()?; let mut recipient_lamports = recipient.try_borrow_mut_lamports()?; require!(**vault_lamports >= amount, PaymentsError::NotEnoughInflowForStep); diff --git a/shine-solana/shine/programs/shine_users/src/lib.rs b/shine-solana/shine/programs/shine_users/src/lib.rs index a4907f8..b59709e 100644 --- a/shine-solana/shine/programs/shine_users/src/lib.rs +++ b/shine-solana/shine/programs/shine_users/src/lib.rs @@ -1007,11 +1007,35 @@ fn transfer_lamports<'a>(payer: &AccountInfo<'a>, recipient: &AccountInfo<'a>, s invoke(&ix, &[payer.clone(), recipient.clone(), system_program_ai.clone()]) } +// Создание PDA, устойчивое к «минированию» детерминированного адреса. +// Адрес будущей записи логина выводится из самого логина, поэтому злоумышленник +// может заранее вычислить адрес и перевести на него немного лампортов обычным +// system-переводом. Тогда обычный system_instruction::create_account упал бы +// («account already in use») и заблокировал бы регистрацию этого логина навсегда. +// Чтобы это исключить, при уже существующих на адресе лампортах создаём аккаунт +// «поверх предзаполненного»: доводим ренту переводом, затем allocate + assign +// под подписью PDA. Подсев чужих лампортов больше ничего не ломает. fn create_pda_account<'a>(payer: &AccountInfo<'a>, pda: &AccountInfo<'a>, system_program_ai: &AccountInfo<'a>, owner: &Pubkey, seeds: &[&[u8]], space: usize) -> ProgramResult { let rent = Rent::get()?; - let lamports = rent.minimum_balance(space); - let ix = system_instruction::create_account(payer.key, pda.key, lamports, space as u64, owner); - invoke_signed(&ix, &[payer.clone(), pda.clone(), system_program_ai.clone()], &[seeds]) + let required_lamports = rent.minimum_balance(space); + let current_lamports = pda.lamports(); + + if current_lamports == 0 { + // Быстрый путь: адрес пуст — обычное создание аккаунта одной инструкцией. + let ix = system_instruction::create_account(payer.key, pda.key, required_lamports, space as u64, owner); + return invoke_signed(&ix, &[payer.clone(), pda.clone(), system_program_ai.clone()], &[seeds]); + } + + // На адресе уже лежат лампорты (вероятно, «подсев» атакующим). Доводим баланс + // до рент-экземпта, выделяем место и назначаем владельцем нашу программу. + let top_up = required_lamports.saturating_sub(current_lamports); + transfer_lamports(payer, pda, system_program_ai, top_up)?; + + let allocate_ix = system_instruction::allocate(pda.key, space as u64); + invoke_signed(&allocate_ix, &[pda.clone(), system_program_ai.clone()], &[seeds])?; + + let assign_ix = system_instruction::assign(pda.key, owner); + invoke_signed(&assign_ix, &[pda.clone(), system_program_ai.clone()], &[seeds]) } fn ensure_pda_size_and_rent<'a>(pda: &AccountInfo<'a>, payer: &AccountInfo<'a>, system_program_ai: &AccountInfo<'a>, required_len: usize) -> ProgramResult {