Program Derived Address (PDA) — це детерміновано згенерована адреса, яка належить вашій програмі, але не має відповідного приватного ключа. Саме ця властивість дозволяє програмі підписувати інструкції через CPI. Помилки в роботі з PDA призводять до втрати контролю над обліковими записами, колізій адрес та непередбачуваної поведінки в production. Нижче розібрано фактичні причини типових помилок і способи їх усунення.

Правильне формування seeds

Seeds — це вхідні дані для функції хешування, яка генерує PDA. Від їхнього складу залежить унікальність адреси та можливість її відтворити в майбутньому. Фундаментальна помилка — використання даних, які можуть змінюватися з часом або залежати від зовнішніх факторів.

Що не можна використовувати як seeds

  • Часові мітки та блок-номери — PDA має бути детермінованим. Якщо ви включите в seeds поточний слот або timestamp, ви не зможете відтворити ту саму адресу пізніше.
  • Дані, які контролює користувач без перевірки — рядки довільної довжини або байтові масиви без фіксованого формату створюють ризик маніпуляцій.
  • Зовнішні стани, що мутують — наприклад, лічильник, який інкрементується при кожному створенні акаунта. Такий підхід робить неможливим відновлення PDA після видалення облікового запису (close).

Безпечний патерн формування seeds

Використовуйте фіксований префікс типу облікового запису, ідентифікатори сутностей та константи. Типова структура:

  • Тип-дискримінатор — фіксований байтовий рядок, наприклад b"vault", b"config", b"order". Це запобігає колізіям між різними типами акаунтів однієї програми.
  • Ідентифікатор власника — публічний ключ користувача або контракту, який логічно повʼязаний із цим PDA.
  • Додатковий контекст — ідентифікатор сесії, mint-адреса токена, номер порядку (якщо він є частиною логічної моделі, а не згенерований лічильником).

У Anchor для цього зазвичай використовують структури з атрибутом seeds, де кожен елемент чітко типізований. Головне правило — ви маєте бути здатні відтворити PDA, маючи лише бізнес-контекст (хто, що, коли створив), без доступу до стану on-chain.

Ризики колізій PDA

Колізія PDA виникає, коли два різні логічні облікові записи отримують однакову адресу. Оскільки PDA генерується з SHA-256 хешу, просторова ємність становить 2256 — випадкова колізія практично неможлива. Проте логічні колізії трапляються регулярно через помилки в дизайні seeds.

Типова сценарія колізії

У програмі є два типи акаунтів: UserProfile та UserSettings. Розробник використовує як єдиний seed публічний ключ користувача для обох типів, забувши додати тип-дискримінатор. Результат — обидва акаунти мапляться на одну адресу. Перша інструкція створює акаунт з даними профілю, друга — перезаписує його налаштуваннями.

Як запобігти колізіям

  • Завжди включайте тип-дискримінатор як перший seed. Навіть якщо зараз у програмі один тип PDA, це зміниться.
  • Не покладайтеся на різну довжину seeds — хеш-функція не гарантує різних результатів для вхідних даних різної довжини.
  • Перевіряйте унікальність на етапі розробки — напишіть тест, який генерує PDA для всіх комбінацій типів та ідентифікаторів і перевіряє, що жодна пара не збігається.
  • Використовуйте namespace-префікси, якщо програма може взаємодіяти з іншими програмами через CPI і передавати seeds. Наприклад, b"my_program:v1:order" замість просто b"order".

Межа застосування: namespace-префікси корисні в екосистемах із багатьма програмами, але збільшують розмір seeds. Для ізольованої програми достатньо тип-дискримінатора.

Перевірка PDA на боці клієнта

Клієнтська перевірка PDA — це не додаткова безпека, а необхідна умова для коректної роботи. Без неї ви відправляєте транзакцію, яка гарантовано впаде на етапі валідації, витрачаючи комісію та створюючи навантаження на кластер.

Що саме перевіряти

  • Адреса збігається з очікуваною — клієнт генерує PDA локально і порівнює з адресою, яку передає в транзакцію.
  • Bump seed збігається — якщо bump зберігається в обліковому записі, клієнт має переконатися, що використовує той самий bump, а не перегенеровує його.
  • Програма-власник правильна — PDA належить саме вашій програмі, а не іншій.

Практична реалізація

На боці клієнта (TypeScript/JavaScript) використовуйте PublicKey.findProgramAddressSync з тими самими seeds і programId, що й у програмі. Результат містить адресу та bump. Адресу порівняйте з тією, яку ви плануєте передати в транзакцію. Bump — із тим, що збережено в обліковому записі (якщо застосовується патерн збереження bump).

Очікуваний результат: якщо адреси не збігаються, транзакція не відправляється. Замість цього клієнт показує помилку або логує невідповідність seeds для діагностики.

Ризик ігнорування: відправка транзакції з неправильним PDA призводить до помилки InstructionError::InvalidAccountData або SeedsMustMatch на етапі валідації. Користувач втрачає комісію за транзакцію, а ви отримуєте хибні метрики відмов у мониторингу.

Небезпечні патерни з derive та find

Функції findProgramAddress та create_program_address (в Anchor — макроси seeds та constraint) мають різну семантику. Плутанина між ними — поширена причина вразливостей.

create_program_address vs findProgramAddress

create_program_address приймає фіксовані seeds і повертає адресу. Якщо результат не лежить на кривій ed25519 — це PDA. Якщо лежить — функція повертає помилку. Ця функція не шукає bump автоматично.

findProgramAddress ітерує bump від 255 до 0, поки не знайде комбінацію, що дає off-curve адресу. Повертає і адресу, і bump.

Небезпечний патерн: виклик create_program_address з жорстко заданим bump (наприклад, 255) і припущення, що він завжди працюватиме. Деякі комбінації seeds з bump=255 дають on-curve адресу, і програма впаде з помилкою InvalidSeeds. Це не вразливість безпеки, але це вразливість доступності — певні користувачі не зможуть створити акаунт.

Збереження bump в обліковому записі

Поширений патерн — зберігати bump у полі акаунта після створення. Це дозволяє не перегенеровувати його кожного разу. Але тут є дві помилки:

  • Не перевіряти збережений bump — якщо акаунт створено з bump=254, а в інструкції використовується bump=253, перевірка Anchor пройде (бо адреса буде іншою, але валідною PDA), але логіка програми буде працювати з неправильним акаунтом. Додайте явний constraint: constraint = vault.bump == bump.
  • Використовувати збережений bump у create_program_address замість findProgramAddress — це коректно, але лише за умови, що bump дійсно був отриманий через findProgramAddress під час створення. Якщо bump записано вручну або з іншого джерела, поведінка непередбачувана.

Передача PDA через CPI без перевірки

Якщо ваша програма отримує PDA від іншої програми через CPI і використовує його для запису даних, обовʼязково перевірте, що цей PDA належить очікуваній програмі. Без такої перевірки зловмисна програма може передати PDA, який належить їй самій, і ваша програма запише дані в чужий обліковий запис.

Перевірка виконується через порівняння поля owner облікового запису з очікуваним programId. У Anchor це робиться автоматично, якщо PDA передано через обліковий запис з правильним типом та constraint. У чистому Rust — явна перевірка account.owner == expected_program_id перед будь-якими діями з акаунтом.

План відкату при виявленні помилки

Якщо ви виявили небезпечний патерн із PDA у production:

  1. Не міняйте seeds існуючих PDA — це зробить усі існуючі акаунти недоступними. Дані не можна відновити без перезапису, що порушує консистентність.
  2. Впровадьте новий патерн для нових акаунтів — додайте тип-дискримінатор або namespace до seeds для нових створень.
  3. Додайте міграційну інструкцію, яка створює нові PDA з правильними seeds, копіює дані та закриває старі акаунти (якщо це допустимо за бізнес-логікою).
  4. Оновіть клієнтську логіку — спочатку шукайте новий PDA, якщо не знайдено — падайте назад до старого формату з логуванням для моніторингу.

Межа: міграція PDA можлива лише за умови, що програма має інструкцію закриття старих акаунтів і передачі lamports. Якщо такої інструкції немає, старі акаунти залишаться недоступними, але безпечними — вони просто займатимуть місце в стані.

Джерела