Cross-Program Invocation (CPI) — це механізм, через який одна програма на Solana делегує виконання іншій програмі в межах однієї транзакції. Кожен такий виклик споживає compute units (CU) із загального бюджету транзакції, і при недостатній увазі до цієї вартості транзакція завершується помилкою ComputationalBudgetExceeded. Нижче — інженерний розбір того, як саме CPI впливає на compute budget, де приховані витрати і як діагностувати перевищення.
Вартість CPI-викликів
Кожен виклик invoke або invoke_signed має фіксовану базову вартість і додаткові витрати, що залежать від переданих облікових записів та розміру даних інструкції.
Базова вартість одного CPI-виклику становить 150 compute units. Ця сума списується незалежно від того, до якої програми йде виклик — системної чи користувацької. Далі runtime додає витрати за кожен обліковий запис, переданий у виклик: чим більший розмір даних (data_len) облікового запису, тим більше CU витрачається на його обробку в контексті виклику.
Крім того, сама програма-отримувач споживає compute units на власну логіку. Ці витрати не включені в базові 150 CU — вони списуються з того самого бюджету транзакції, але вже в контексті викликаної програми. Точне значення базової вартості варто перевірити в поточній версії runtime-коду Solana у файлі compute_budget.rs, оскільки воно може змінюватися з оновленнями.
Що формує сумарну вартість
- Базовий виклик CPI — 150 CU за кожен invoke або invoke_signed.
- Облікові записи — витрати залежать від data_len кожного переданого облікового запису. Порожній обліковий запис коштує менше, ніж обліковий запис із кількома кілобайтами даних.
- Дані інструкції — розмір instruction_data, переданого через CPI, також впливає на витрати.
- Виконання програми-отримувача — власна логіка цільової програми, включно з її внутрішніми операціями читання й запису, криптографією тощо.
Як виміряти фактичну вартість
Найнадійніший спосіб — використати getComputeUnitConsumed у тестах або проаналізувати логи симуляції транзакції через RPC-метод simulateTransaction із прапорцями replaceRecentBlockhash та sigVerify: false. У відповіді RPC повертає поле unitsConsumed, яке показує сумарне споживання CU для всієї транзакції, включно з усіма рівнями CPI.
Для точного вимірювання внеску конкретного CPI-виклику порівняйте unitsConsumed транзакції з викликом і без нього. Різниця дасть реальну вартість цього CPI у вашому конкретному контексті.
Обмеження глибини вкладених CPI
Runtime Solana жорстко обмежує глибину вкладених викликів — максимум 4 рівні. Це означає, що якщо програма A викликає програму B (рівень 1), яка викликає C (рівень 2), яка викликає D (рівень 3), яка викликає E (рівень 4) — подальший CPI з програми E завершиться помилкою CallDepthLimitExceeded.
Що рахується за рівень
Кожен виклик invoke або invoke_signed збільшує лічильник глибини на одиницю. Повернення з виклику зменшує його. Важливо: виклик самої себе (рекурсія) також рахується, тому рекурсивні патерни в межах CPI швидко вичерпують ліміт.
Типові сценарії, де це стає проблемою
- Маршрутизатори інструкцій — коли програма делегує виконання через ланцюжок проміжних програм-маршрутизаторів.
- Дефі-протоколи зі складними маршрутами свопів — кожен проміжний пул може бути окремою програмою.
- Реєстрі або проксі-програми — коли інструкція проходить через кілька рівнів абстракції перед потраплянням до цільової логіки.
Як перевірити поточну глибину
У тестовому середовищі можна інструментувати виклики та логувати глибину. У production глибину можна визначити за стеком трасування в логах симуляції: кожен CPI-виклик додає рівень у лог-вивід. Якщо ви бачите 4 послідовні вкладені виклики і помилку CallDepthLimitExceeded — межу досягнуто.
План відкату при досягненні ліміту
Якщо архітектура потребує більшої глибини, єдиний коректний підхід — перепроєктування: об'єднання проміжних програм, перенесення логіки на меншу кількість рівнів або використання прямого виклику цільової програми замість ланцюжка. Зменшення кількості рівнів CPI не потребує міграції даних, але вимагає оновлення клієнтського коду, який формує транзакцію.
Оптимізація даних, що передаються через CPI
Оскільки вартість CPI залежить від розміру переданих облікових записів та даних інструкції, оптимізація цих параметрів безпосередньо зменшує споживання compute budget.
Мінімізація кількості облікових записів
Передавайте в CPI лише ті облікові записи, які дійсно потрібні програмі-отримувачу для виконання інструкції. Кожен додатковий обліковий запис — це додаткові CU на перевірку підписів, власника та лімітів. Перевірте документацію цільової програми: деякі облікові записи, які здаються обов'язковими, можуть бути опціональними за певних умов.
Зменшення розміру даних інструкції
Серіалізуйте лише ті поля, які потрібні цільовій програмі. Якщо програма-отримувач очікує структуру з 10 полями, але для конкретного виклику достатньо 3 — розгляньте можливість використання різних типів інструкцій або компактніших форматів. Загальне питання оптимізації серіалізації в Anchor розглядато окремо, тут фокус саме на тому, що передається через кордон CPI.
Уникнення передачі великих облікових записів як read-only
Якщо програмі-отримувачу потрібен лише малий фрагмент даних із великого облікового запису, передавання всього облікового запису через CPI споживає CU пропорційно його повному data_len. У таких випадках варто розглянути альтернативні архітектурні рішення: попереднє витягування потрібних даних у менший обліковий запис або зміну контракту так, щоб великий обліковий запис не був потрібний у цьому CPI.
Не дублюйте дані
Поширена помилка — передавати одні й ті самі дані і через облікові записи, і через instruction_data. Це подвійно споживає CU. Виберіть один канал: або дані в обліковому записі, або в інструкції.
Діагностика перевищення бюджету через CPI
Коли транзакція завершується з помилкою ComputationalBudgetExceeded, перше завдання — визначити, чи CPI є причиною, і який саме виклик споживає найбільше.
Крок 1. Симуляція транзакції через RPC
Викличте simulateTransaction із параметром encoding: "jsonParsed" та увімкніть детальні логи. У відповіді шукайте рядки, що містять log з інформацією про виклики програм. Кожен вхід у програму через CPI позначається в логах. Порівняйте логи до і після підозрілого виклику — різниця в обсязі логічної роботи часто вказує на джерело перевитрати.
Крок 2. Ізоляція CPI-виклику
Створіть тестову транзакцію, яка містить лише підозрілий CPI-виклик із мінімально необхідними обліковими записами. Виміряйте unitsConsumed. Якщо цей виклик сам по собі споживає значну частину бюджету — проблема в ньому. Якщо ні — перевитрата накопичується з інших частин транзакції.
Крок 3. Аналіз облікових записів, що передаються
Для кожного облікового запису у виклику перевірте data_len. Використовуйте RPC-метод getAccountInfo і звірте розміри. Якщо в CPI передається обліковий запис на 10 КБ даних, а програмі потрібні лише 32 байти — це прямий кандидат на оптимізацію.
Крок 4. Перевірка глибини вкладеності
Якщо підозрілий CPI сам робить подальші CPI-виклики, перевірте загальну глибину. Навіть якщо кожен окремий виклик недорогий, 4 рівні вкладеності множать базові витрати (4 × 150 CU = 600 CU лише за базові виклики) плюс витрати на облікові записи на кожному рівні.
Крок 5. Збільшення бюджету як тимчасовий захід
Якщо діагностика показала, що CPI споживає багато, але логіка коректна і не підлягає спрощенню, можна збільшити compute budget транзакції за допомогою інструкції ComputeBudgetInstruction::setComputeUnitLimit. Перевірте поточний максимальний ліміт за допомогою getComputeBudget через RPC, оскільки це значення може змінюватися з оновленнями мережі. Це тимчасове рішення: воно не усуває проблему, а лише розширює межу.
Крок 6. Верифікація виправлення
Після оптимізації повторіть кроки 1–2 і порівняйте unitsConsumed до та після. Переконайтеся, що транзакція проходить із запасом не менше 10–15% від виділеного бюджету — це захист від коливань споживання через зміни в даних облікових записів або оновлення залежних програм.
Типові помилки в діагностиці
- Припущення, що CPI «безкоштовний» — базові 150 CU за виклик часто ігноруються, але при багаторазових викликах накопичуються.
- Пошук причини в логіці програми, а не в облікових записах — іноді сама логіка CPI легка, але передача великого облікового запису споживає більше CU, ніж виконання коду.
- Ігнорування змін у залежних програмах — якщо програма-отримувач оновилася і її логіка стала дорожчою у compute units, ваш CPI почне споживати більше без змін у вашому коді.
Системний підхід до вимірювання, ізоляції та оптимізації CPI-викликів дозволяє довести транзакції зі складною міжпрограмною взаємодією до стабільного проходження в production без надмірного збільшення compute budget.