SHiNE-server/shine-solana/shine/doc/programs/shine_payments.md
AidarKC cf6a2830c8 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>
2026-06-11 04:10:31 +04:00

440 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Программа `shine_payments`
Документ описывает текущее целевое поведение программы `shine_payments`.
Текущая целевая реализация программы:
- без Anchor;
- без использования вспомогательной зависимости из `programs/common`;
- на чистом `solana_program` с ручным разбором инструкций, PDA и состояний.
Назначение программы:
- хранить общие настройки экономической модели платежей и выплат;
- принимать покупку тикетов очереди выплат;
- хранить три очереди и отдельные ticket PDA;
- выдавать лимиты менеджерам на ручное добавление тикетов;
- выполнять пошаговые выплаты из inflow-вольта.
Если код и этот документ расходятся, нужно в том же изменении синхронизировать либо код, либо документ.
## 1. Program ID
Текущий program id:
- `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW`
## 2. Основная модель
`shine_payments` разделяет две вещи:
1. регистрацию долга/обязательств через тикеты;
2. фактическую выплату через отдельный inflow-вольт.
Это важно:
- покупка тикета не означает немедленную выплату из вольта;
- payout выполняется отдельной инструкцией `step_payout`;
- inflow-вольт должен быть заранее или отдельно пополнен.
## 3. PDA и seed-правила
### 3.1. Single PDA
- config: `shine_payments_config`
- coef/limit: `shine_payments_coef_limit`
- queues: `shine_payments_queues`
- inflow vault: `shine_payments_inflow_vault`
### 3.2. Ticket PDA
- queue 1 seed prefix: `shine_payments_q1_ticket`
- queue 2 seed prefix: `shine_payments_q2_ticket`
- queue 3 seed prefix: `shine_payments_q3_ticket`
- второй seed: `ticket_index` в little-endian `u64`
### 3.3. Manager allowance PDA
- 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`
Поля:
- `version: u8`
- `dao_wallet: Pubkey`
- `inflow_vault: Pubkey`
### 4.2. `CoefLimitState`
Поля:
- `version: u8`
- `coef_ppm: u64`
- `limit_usd_cents: u64`
- `call_reward_lamports: u64`
Смысл:
- `coef_ppm` — множитель payout в ppm;
- `limit_usd_cents` — лимит суммы очереди Q1;
- `call_reward_lamports` — награда вызывающему `step_payout`.
### 4.3. `QueuesState`
Поля агрегатов:
- `q1_tickets_total`
- `q1_tickets_paid`
- `q1_sum_total_usd_cents`
- `q1_sum_paid_usd_cents`
- `q2_tickets_total`
- `q2_tickets_paid`
- `q2_sum_total_usd_cents`
- `q2_sum_paid_usd_cents`
- `q3_tickets_total`
- `q3_tickets_paid`
- `q3_sum_total_usd_cents`
- `q3_sum_paid_usd_cents`
### 4.4. `TicketState`
Поля:
- `version: u8`
- `queue_id: u8`
- `index: u64`
- `is_paid: bool`
- `recipient_wallet: Pubkey`
- `payout_usd_cents: u64`
- `debt_before_usd_cents: u64`
### 4.5. `ManagerAllowanceState`
Поля:
- `version: u8`
- `manager_wallet: Pubkey`
- `q1_available_usd_cents: u64`
- `q2_available_usd_cents: u64`
- `q3_available_usd_cents: u64`
### 4.6. `VaultState`
Поля:
- `version: u8`
Это техническая метка существования inflow PDA. Лампорты лежат на самом PDA-аккаунте.
## 5. Константы и экономика
Текущие базовые значения:
- `COEF_SCALE_PPM = 1_000_000`
- `START_COEF_PPM = 5_000_000` (5.0x)
- `START_LIMIT_USD_CENTS = 1_000_000` (10_000 USD)
- `START_CALL_REWARD_LAMPORTS = 8_000_000`
- `MAX_CALL_REWARD_LAMPORTS = 10_000_000`
- `ORACLE_MAX_AGE_SECS = 120`
- цена SOL/USD берётся из Pyth.
## 6. Инструкция `init`
### Назначение
Инициализировать все базовые PDA программы.
### Создаваемые PDA
- `config_pda`
- `coef_limit_pda`
- `queues_pda`
- `inflow_vault_pda`
### Кто может вызвать
Технически любой signer, если система ещё не инициализирована.
### Что записывается
- `dao_wallet` берётся из settings/deploy config;
- `inflow_vault` указывается как PDA самой программы;
- коэффициенты и лимиты пишутся стартовыми значениями;
- очереди стартуют с нулями.
## 7. Инструкция `update_coef_limit`
### Назначение
Изменить коэффициент выплат, лимит очереди Q1 и награду за шаг выплат.
### Авторизация
Только `dao_wallet` из `ConfigState`.
### Проверки
- signer = `config.dao_wallet`
- `coef_ppm > 0`
- `limit_usd_cents > 0`
- `call_reward_lamports <= MAX_CALL_REWARD_LAMPORTS`
## 8. Инструкция `grant_manager_limits`
### Назначение
Выдать менеджеру квоты на добавление тикетов вручную.
### Авторизация
Только DAO.
### Поведение
- создаёт `manager_allowance_pda`, если её ещё нет;
- увеличивает доступные лимиты Q1/Q2/Q3 для указанного менеджера;
- не добавляет тикеты сама.
## 9. Инструкции покупки тикета
Есть три входных варианта:
- `buy_ticket`
- `buy_ticket_usd`
- `buy_ticket_sol`
Все они в итоге сводятся к одному действию:
- рассчитывается `purchase_usd_cents`;
- создаётся ticket в очереди `Q1`;
- обновляются агрегаты очереди `Q1`.
### 9.1. Общие правила
- ticket создаётся только в `Q1`;
- очередь `Q1` временно блокируется, если достигнут её суммарный лимит;
- `payout_usd_cents = purchase_usd_cents * coef_ppm / COEF_SCALE_PPM`;
- `recipient_wallet` записывается в ticket;
- `recipient_wallet` **не должен совпадать с адресом inflow-вольта** (см. §10.1).
### 9.2. Важная деталь денежного потока
В текущей реализации при покупке тикета лампорты переводятся:
- не во inflow vault;
- а напрямую в `dao_wallet`.
То есть:
- ticket фиксирует долг на будущую выплату;
- а inflow vault — отдельный источник средств для исполнения payouts.
Эта деталь обязательно должна быть осознана при переписи: либо её сохраняют как сознательную модель, либо отдельно меняют и тогда обновляют этот документ.
## 10. Инструкция `manager_add_ticket`
### Назначение
Дать менеджеру возможность вручную создать ticket в `Q1`, `Q2` или `Q3`.
### Авторизация
Signer должен совпадать с `manager_wallet` в `manager_allowance_pda`.
### Проверки
- `queue_id` только `1`, `2` или `3`;
- `payout_usd_cents > 0`;
- `recipient_wallet` не равен адресу inflow-вольта (см. §10.1);
- доступный allowance по очереди не меньше суммы тикета;
- ticket PDA ещё не существует.
### Эффект
- создаётся ticket;
- 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`
### Назначение
Сделать один шаг исполнения очереди выплат.
### Выбор очереди
- если в `Q1` есть pending ticket, сначала обслуживается `Q1`;
- если `Q1` пустая, обслуживается `Q2`;
- если `Q1` и `Q2` пустые, обслуживается `Q3`.
### Что считается pending
```text
pending = tickets_total - tickets_paid
```
### Как выбирается ticket
Берётся следующий индекс:
```text
next_index = tickets_paid + 1
```
### Проверки
- `config_pda`, `coef_limit_pda`, `queues_pda`, `inflow_vault_pda` валидны;
- `dao_wallet` совпадает с `config.dao_wallet`;
- `next_ticket_pda` совпадает с PDA следующего тикета;
- тикет ещё не оплачен;
- `ticket_recipient_wallet` совпадает с `ticket.recipient_wallet`;
- в inflow vault достаточно средств на весь шаг.
### Сколько переводится
Для target queue:
- получателю тикета переводится `ticket_lamports`;
- DAO переводится `dao_lamports`;
- вызвавшему шаг переводится `call_reward_lamports`.
`dao_multiplier`:
- для `Q1` = `1`
- для `Q2` = `2`
- для `Q3` = `3`
То есть DAO получает:
- `1x payout_usd` для Q1;
- `2x payout_usd` для Q2;
- `3x payout_usd` для Q3.
### Источник денег
Все три перевода идут из inflow vault PDA.
### Если pending нет
Если все три очереди пусты:
- весь доступный остаток inflow vault переводится в DAO wallet.
## 12. Инструкция `change_ticket_recipient`
### Назначение
Позволить текущему получателю тикета поменять адрес получения, пока ticket ещё не исполнен.
### Авторизация
Только текущий `ticket.recipient_wallet`.
### Ограничения
`new_recipient_wallet` не должен совпадать с адресом inflow-вольта (см. §10.1).
Нельзя менять получателя у следующего тикета на выплату в активной очереди.
Логика:
- если в `Q1` есть pending — следующий ticket определяется в `Q1`;
- иначе если в `Q2` есть pending — следующий ticket берётся в `Q2`;
- иначе следующий ticket берётся в `Q3`;
- если текущий ticket и есть этот ближайший ticket, смена recipient запрещена.
## 13. Pyth oracle и конвертация
Программа использует Pyth SOL/USD.
Проверки oracle:
- передан именно тот oracle account, что указан в settings;
- owner oracle-аккаунта совпадает с Pyth Solana Receiver program;
- feed id совпадает с ожидаемым `PYTH_SOL_USD_FEED_ID`;
- verification level должен быть `Full`;
- цена не старше `ORACLE_MAX_AGE_SECS`;
- доверительный интервал (`conf`) не должен быть шире `ORACLE_MAX_CONFIDENCE_PPM`;
- цена положительная и корректно переводима в ratio.
Реализация чтения:
- для декодирования price update используется официальный open-source `pyth-solana-receiver-sdk`;
- ручной парсинг по фиксированным offset-ам не используется.
Внутренние преобразования:
- `lamports -> usd_cents` делаются с округлением вниз;
- `usd_cents -> lamports` делаются с округлением вверх.
Это важно, чтобы не недоплачивать обязательства при payout.
## 14. Ошибки и классы отказа
Программа должна различать как минимум:
- неверный inflow vault;
- неверный DAO wallet;
- неавторизованный DAO;
- неавторизованный manager;
- неверный manager allowance PDA;
- неверный queue id;
- тикет уже выплачен;
- неверный recipient;
- нельзя сменить recipient ближайшего тикета;
- недостаточно средств inflow vault для step payout;
- queue temporarily paused из-за лимита;
- oracle account/feed/price invalid;
- slippage exceeded.
## 15. Что должно сохраниться при переписи без Anchor
Обязательно сохранить:
- те же PDA seed-правила;
- те же состояния и поля;
- ту же модель очередей Q1/Q2/Q3;
- ту же приоритетность `Q1 -> Q2 -> Q3` в `step_payout`;
- ту же логику allowance менеджера;
- те же oracle-ограничения и округления;
- ту же текущую модель денежных потоков, если она не меняется отдельным решением.
Если при переписи вы решите поменять экономическую модель, сначала нужно обновить этот документ.