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

16 KiB
Raw Blame History

Программа 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

pending = tickets_total - tickets_paid

Как выбирается ticket

Берётся следующий индекс:

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-ограничения и округления;
  • ту же текущую модель денежных потоков, если она не меняется отдельным решением.

Если при переписи вы решите поменять экономическую модель, сначала нужно обновить этот документ.