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
@@ -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::<QueuesState>(queues_pda)?;
let mut ticket = read_state::<TicketState>(ticket_pda)?;
require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid);
@@ -1010,6 +1019,11 @@ fn buy_ticket_by_purchase_usd(
let mut queues = read_state::<QueuesState>(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<T: StateCodec>(pda: &AccountInfo, state: &T) -> ProgramResult {
@@ -1219,9 +1251,15 @@ fn read_state<T: StateCodec>(pda: &AccountInfo) -> Result<T, ProgramError> {
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);