Розмір облікового запису в Solana безпосередньо визначає мінімальний депозит для звільнення від оренди (rent-exempt minimum) та загальну вартість створення й утримання даних на ланцюгу. Коли обліковий запис наближається до граничних 10 КБ або коли орендний депозит стає економічно невиправданим, оптимізація простору переходить з категорії бажаних покращень у категорію технічних обмежень. Нижче — покрокова інженерна методологія зменшення розміру облікового запису: від точного вимірювання до безпечної міграції.

Аналіз поточного використання простору

Вимірювання фактичного розміру

Перш ніж оптимізувати, необхідно отримати точні цифри. Два основні способи:

  • CLI: команда solana account <ADDRESS> повертає поле Size у байтах.
  • RPC: виклик getAccountInfo з параметром encoding: "base64" повертає поле data (масив байтів) та dataLen. Значення dataLen — це фактичний розмір даних облікового запису.

Зверніть увагу: dataLen може бути більшим за суму розмірів полів вашої структури, якщо обліковий запис було створено з запасом (наприклад, через realloc у більший бік без зменшення назад).

Розбивка за полями

Загального інструменту для автоматичної розбивки Borsh-серіалізованих даних за полями немає. Практичний підхід — написати скрипт (Rust або TypeScript), який десеріалізує обліковий запис і обчислює розмір кожного поля за правилами Borsh:

Тип даних Розмір у Borsh (байти)
u8, i8, bool 1
u16, i16 2
u32, i32 4
u64, i64, f64 8
Pubkey 32
Option<T> 1 (дескримінант) + розмір T (якщо Some)
Vec<T> 4 (довжина як u32) + розмір T × кількість елементів
String 4 (довжина як u32) + кількість UTF-8 байтів
[T; N] розмір T × N (без префікса довжини)
Enum з даними 1 (дескримінант) + розмір найбільшого варіанту

Скрипт має вивести таблицю: назва поля, тип, розмір у байтах, частка від загального обсягу. Це дозволяє одразу побачити, які поля споживають найбільше простору.

Визначення пріоритетів оптимізації

Сортуйте поля за спожитим простором у порядку спадання. Оптимізація поля на 1000 байтів дає більший ефект, ніж економія 2 байтів на десяти полях. Фокусуйтеся на:

  • Колекціях (Vec) з великою кількістю елементів — особливо Vec<Pubkey> та Vec<u64>.
  • Рядках (String) з надлишковими даними.
  • Резервних масивах (наприклад, [u8; 128]), які заповнені частково або не використовуються взагалі.

Оптимізація структур даних

Правильний вибір цілочисельних типів

Найпоширеніше джерело надлишкового споживання — використання u64 там, де достатньо меншого типу. Кожне таке поле, замінене з u64 на u32, економить 4 байти; на u16 — 6 байтів; на u8 — 7 байтів.

Критерій вибору: максимальне фактичне значення поля, помножене на запас (наприклад, ×2 або ×10 залежно від бізнес-логіки), має поміщатися в обраний тип. Наприклад, якщо лічильник транзакцій користувача ніколи не перевищить 65 535, u16 достатньо замість u64 — економія 6 байтів на одному полі.

Типові кандидати на звуження типу:

  • Лічильники, індекси, порядкові номери.
  • Таймстемпи (якщо потрібна точність до секунд, а не до наносекунд — u32 вміщує ~136 років у секундах).
  • Відсотки, комісії, ваги (з фіксованою точкою замість плаваючої).

Обробка необов'язкових значень

Option<T> у Borsh коштує 1 байт додатково поверх типу T. Якщо у вас є кілька незалежних необов'язкових прапорців (Option<bool>), кожен з них займає 2 байти. Заміна трьох таких полів на одне поле u8 з бітовими прапорцями зменшує споживання з 6 байтів до 1.

Для Option<u64> (9 байтів) розгляньте можливість заміни на u64 зі спеціальним значенням-маркером (наприклад, u64::MAX для "не встановлено"), якщо таке значення не є валідним для бізнес-логіки. Це економить 1 байт на поле.

Порядок полів та серіалізація Borsh

На відміну від деяких інших форматів серіалізації, Borsh записує поля структури строго в порядку їх оголошення в коді без вставки вирівнювання (padding) між полями. Це означає, що перестановка полів у структурі не змінює загальний розмір серіалізованих даних. Однак перестановка змінює бінарний формат, що робить існуючі облікові записи недесеріалізованими — це вимагає міграції (про це нижче).

Практичний висновок: порядок полів не впливає на розмір у Borsh, але будь-яка зміна порядку — це зміна схеми, яка потребує міграції даних.

Zero-copy структури: окремий випадок

Якщо ви використовуєте zero_copy (атрибути #[account(zero_copy)] в Anchor або ручна робота з Pod-трейтами), дані відображаються безпосередньо на пам'ять без серіалізації. У цьому випадку Rust-вирівнювання (alignment) працює повною мірою: компілятор може додавати заповнення між полями для вирівнювання за адресою.

Приклад: структура з полем u8 (1 байт, вирівнювання 1) та полем u64 (8 байтів, вирівнювання 8) у zero-copy займе 16 байтів замість 9 — 7 байтів заповнення між полями. У звичайному Borsh це було б 9 байтів.

Для zero-copy структур правило просте: групуйте поля за спаданням розміру вирівнювання (u64 та Pubkey спочатку, потім u32, потім u16, потім u8). Або використовуйте атрибут #[repr(C)] і явно контролюйте порядок. Перевіряйте фактичний розмір через std::mem::size_of::<YourStruct>().

Компресія повторюваних полів

Бітові маски замість масивів булевих значень

Масив Vec<bool> з N елементів у Borsh займає 4 + N байтів (4 байти на довжину + 1 байт на елемент). Заміна на u8 з бітовою маскою дозволяє зберегти до 8 прапорців у 1 байті без префікса довжини. Для 32 прапорців економія становить (4 + 32) − 4 = 32 байти. Для 256 прапорців — (4 + 256) − 32 = 228 байтів.

Реалізація: зберігайте бітову маску як [u8; K], де K = ceil(N / 8). Доступ до i-го біта: mask[i / 8] & (1 << (i % 8)).

Таблиці пошуку для повторюваних Pubkey

Це одна з найпотужніших оптимізацій для облікових записів, що зберігають колекції Pubkey. Кожен Pubkey займає 32 байти. Якщо в обліковому записі зберігається 100 Pubkey — це 3200 байтів лише на ключі.

Патерн: створюється окремий обліковий запис-таблиця пошуку (lookup account), який містить масив Pubkey. В основному обліковому записі замість Pubkey зберігаються індекси (u16, 2 байти) у цю таблицю. Економія на одному посиланні: 30 байтів. На 100 посиланнях: 3000 байтів.

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

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

Коли компресія недоцільна

  • Малі обсяги даних. Економія 10–20 байтів рідко виправдовує складність додаткової логіки серіалізації/десеріалізації та ризики помилок.
  • Збільшення compute-витрат. Доступ до стиснених даних потребує додаткових обчислень (бітові операції, індексація в таблицю пошуку). Якщо обліковий запис читається часто в межах однієї транзакції, перевірте, чи не перевищить це ліміт compute units. Оцінку compute-витрат виконуйте окремо — це тема сусіднього розділу.
  • Втрата зручності на клієнті. Якщо стиснуті дані потрібно часто розкодовувати на фронтенді, переконайтеся, що клієнтська бібліотека коректно працює з новим форматом.

Міграція даних при зміні схеми

Будь-яка зміна структури облікового запису, що змінює бінарний формат Borsh (додавання, видалення, перестановка полів, зміна типів), робить існуючі облікові записи несумісними з новим кодом. Міграція обов'язкова.

Перерозподіл через realloc

Системний виклик realloc дозволяє змінити розмір даних облікового запису без створення нового. Підходить для сценаріїв, де схема не змінюється, а потрібно лише звільнити зайвий простір (наприклад, після видалення елементів з Vec).

Передумови:

  • Обліковий запис повинен бути writable.
  • Платник (payer) повинен мати достатньо SOL для збереження rent-exempt статусу після збільшення (при зменшенні — додаткові SOL не потрібні).
  • Перевірте актуальні ліміти на розмір перерозподілу за одну інструкцію в документації Solana — ці значення змінюються з оновленнями клієнта.

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

Важливий нюанс: при зменшенні розміру через realloc надлишковий SOL не повертається автоматично. Rent-exempt мінімум зменшується, але різниця залишається на балансі облікового запису. Щоб повернути SOL, потрібен окремий виклик інструкції, яка зніме зайві кошти (withdraw або close).

Ризик: якщо викликати realloc з меншим розміром, ніж фактичний обсяг серіалізованих даних, дані будуть пошкоджені. Завжди серіалізуйте дані в новий формат до виклику realloc і перевіряйте отриманий розмір.

Патерн двох облікових записів

Для зміни схеми (додавання/видалення полів, зміна типів) використовується патерн створення нового облікового запису з оптимізованою структурою, копіювання даних зі старого та закриття старого.

Кроки міграції в межах однієї інструкції:

  1. Створити новий обліковий запис з оптимізованою структурою (менший розмір, відповідний rent-exempt депозит).
  2. Десеріалізувати старий обліковий запис за старою схемою.
  3. Заповнити нову структуру даними зі старої, застосовуючи перетворення (звуження типів, компресію, заміну Pubkey на індекси тощо).
  4. Серіалізувати нову структуру та записати в новий обліковий запис.
  5. Закрити старий обліковий запис — rent-exempt депозит повертається вказаному адресату.

Переваги: атомарність (всі кроки в одній транзакції), повернення SOL з старого облікового запису, немає ризику пошкодження даних через realloc.

Обмеження: якщо обліковий запис є PDA (Program Derived Address), його адреса визначена seeds. Створення "нового" PDA з тими самими seeds неможливе — це той самий обліковий запис. У цьому випадку потрібен або realloc (якщо зміна схеми сумісна з існуючим обліковим записом), або перехід на нові seeds із міграцією посилань у всіх пов'язаних облікових записах.

План відкату та перевірка

Перед розгортанням міграції в mainnet:

  1. Реалізуйте міграцію на devnet. Створіть тестові облікові записи з реальними даними (не синтетичними — експортуйте з mainnet через RPC, якщо це допустимо з погляду безпеки).
  2. Перевірте розмір. Після міграції викличте getAccountInfo і порівняйте dataLen з очікуваним розміром нової структури. Вони мають збігатися байт-в-байт.
  3. Перевірте цілісність даних. Десеріалізуйте новий обліковий запис за новою схемою і порівняйте кожне поле з вихідними даними. Зверніть увагу на звуження типів — перевірте граничні значення, при яких може відбутися переповнення.
  4. Перевірте всі споживачі. Знайдіть усі інструкції та CPI-виклики, що читають цей обліковий запис, і переконайтеся, що вони працюють з новою схемою.
  5. Зафіксуйте план відкату. Якщо міграція використовує патерн двох облікових записів, відкат можливий лише якщо старий обліковий запис ще не закрито. Зберігайте старі облікові записи до повного верифікування нової схеми в production. Якщо міграція вже проведена і старі облікові записи закриті, відкат вимагає зворотної міграції (з нової схеми в стару) — реалізуйте її заздалегідь як окрему інструкцію.

Типова помилка: розгортання нової програми з оновленою схемою без міграції існуючих облікових записів. Результат — всі існуючі облікові записи стають недоступними для нової програми (помилка десеріалізації). Це не помилка інфраструктури, а логічна помилка розробника: зміна формату даних без перетворення наявних екземплярів.

Наступний логічний крок: після оптимізації розміру облікових записів та проведення міграції перевірте, чи не змінилася потреба програми в compute units — компактніші структури можуть зменшити обсяг серіалізації/десеріалізації, але додаткова логіка (індексація, розпакування) може її збільшити. Оцінку compute-витрат виконуйте окремо.

Джерела