solana: закрыть griefing создания PDA и заморозку выплат, добавить аудит №2

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 <noreply@anthropic.com>
This commit is contained in:
AidarKC
2026-06-11 04:10:31 +04:00
co-authored by Claude Fable 5
parent 01d9553db4
commit cf6a2830c8
9 changed files with 389 additions and 16 deletions
@@ -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).
Нельзя менять получателя у следующего тикета на выплату в активной очереди.
Логика:
@@ -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`