Основи Pinocchio
Що таке Pinocchio
Хоча більшість розробників Solana покладаються на Anchor, існує багато вагомих причин писати програму без нього. Можливо, вам потрібен більш детальний контроль над кожним полем облікового запису, або ви прагнете максимальної продуктивності, або просто хочете уникнути макросів.
Розробка програм Solana без фреймворку на кшталт Anchor відома як нативна розробка. Вона більш вимоглива, але в цьому курсі ви навчитеся створювати програму Solana з нуля за допомогою Pinocchio — легкої бібліотеки, яка дозволяє обійтися без зовнішніх фреймворків і контролювати кожен байт вашого коду.
Pinocchio — це мінімалістична бібліотека Rust, яка дозволяє створювати програми Solana без використання важкого крейту solana-program
. Вона працює, розглядаючи вхідне навантаження транзакції (облікові записи, дані інструкцій, все) як єдиний байтовий зріз і читає його на місці за допомогою техніки нульового копіювання.
Ключові переваги
Мінімалістичний дизайн відкриває три великі переваги:
- Менше обчислювальних одиниць. Без додаткової десеріалізації чи копіювання пам'яті.
- Менші бінарні файли. Стрункіші шляхи коду означають легший
.so
в мережі. - Нульове навантаження від залежностей. Немає зовнішніх крейтів для оновлення (або поломки).
Проєкт був започаткований Febo в Anza з основним внеском від екосистеми Solana та команди Blueshift, і знаходиться тут.
Поряд з основним крейтом, ви знайдете pinocchio-system
та pinocchio-token
, які надають допоміжні засоби нульового копіювання та утиліти CPI для нативних програм System та SPL-Token від Solana.
Нативна розробка
Нативна розробка може здаватися складною, але саме для цього існує цей розділ. До кінця ви зрозумієте кожен байт, що перетинає межу програми, і як зробити вашу логіку чіткою, безпечною та швидкою.
Anchor використовує Процедурні та Похідні Макроси для спрощення шаблонного коду при роботі з рахунками, даними інструкцій та обробкою помилок, що є основою побудови Програм Solana.
Перехід до Нативного підходу означає, що ми втрачаємо цю перевагу і нам потрібно буде:
- Створити власний Дискримінатор та Точку входу для різних Інструкцій
- Створити власну логіку для Рахунків, Інструкцій та десеріалізації
- Реалізувати всі перевірки безпеки, які раніше Anchor робив за нас
Примітка: Наразі не існує "фреймворку" для створення програм Pinocchio. З цієї причини ми представимо те, що вважаємо найкращим способом написання програм pinocchio на основі нашого досвіду.
Точка входу
В Anchor макрос #[program]
приховує багато внутрішніх з'єднань. Під капотом він створює 8-байтовий дискримінатор (розмір можна налаштувати з версії 0.31) для кожної інструкції та структури рахунків.
Нативні програми зазвичай тримають речі більш компактними. Однобайтового дискримінатора (значення 0x01...0xFF) достатньо для 255 інструкцій, чого вистачає для більшості випадків. Якщо потрібно більше, можна перейти на двобайтовий варіант, розширивши до 65 535 можливих варіантів.
Макрос entrypoint!
— це місце, де починається виконання програми. Він надає три необроблені зрізи:
- program_id: публічний ключ розгорнутої програми
- accounts: всі рахунки, передані в інструкції
- instruction_data: непрозорий масив байтів, що містить ваш дискримінатор плюс будь-які дані, надані користувачем
Це означає, що після точки входу ми можемо створити шаблон, який виконує всі різні інструкції через відповідний обробник, який ми назвемо process_instruction
. Ось як це зазвичай виглядає:
entrypoint!(process_instruction);
fn process_instruction(
_program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
match instruction_data.split_first() {
Some((Instruction1::DISCRIMINATOR, data)) => Instruction1::try_from((data, accounts))?.process(),
Some((Instruction2::DISCRIMINATOR, _)) => Instruction2::try_from(accounts)?.process(),
_ => Err(ProgramError::InvalidInstructionData)
}
}
За лаштунками цей обробник:
- Використовує
split_first()
для вилучення байту дискримінатора - Використовує
match
для визначення, який структурний тип інструкції створити - Реалізація
try_from
кожної інструкції перевіряє та десеріалізує її вхідні дані - Виклик
process()
виконує бізнес-логіку
Різниця між solana-program
та pinocchio
Основна відмінність та оптимізація полягає в тому, як працює entrypoint()
.
- Стандартні точки входу Solana використовують традиційні шаблони серіалізації, де середовище виконання десеріалізує вхідні дані заздалегідь, створюючи власні структури даних у пам'яті. Цей підхід широко використовує серіалізацію Borsh, копіює дані під час десеріалізації та виділяє пам'ять для структурованих типів даних.
- Точки входу Pinocchio реалізують операції з нульовим копіюванням, зчитуючи дані безпосередньо з вхідного масиву байтів без копіювання. Фреймворк визначає типи з нульовим копіюванням, які посилаються на оригінальні дані, усуває накладні витрати на серіалізацію/десеріалізацію та використовує прямий доступ до пам'яті, щоб уникнути рівнів абстракції.
Accounts and Instructions
Оскільки у нас немає макросів, і ми хочемо уникати їх, щоб програма залишалася легкою та ефективною, кожен байт даних інструкцій та облікових записів має бути перевірений вручну.
Щоб зберегти цей процес організованим, ми використовуємо шаблон, який забезпечує ергономіку в стилі Anchor без макросів, зберігаючи фактичний метод process()
майже без шаблонного коду, реалізуючи трейт Rust TryFrom
.
Трейт TryFrom
TryFrom
є частиною стандартної родини конверсій Rust. На відміну від From
, який припускає, що конверсія не може завершитися невдачею, TryFrom
повертає Result
, що дозволяє виявляти помилки на ранніх етапах - ідеально для перевірки в ланцюжку.
Трейт визначається так:
pub trait TryFrom<T>: Sized {
type Error;
fn try_from(value: T) -> Result<Self, Self::Error>;
}
У програмі Solana ми реалізуємо TryFrom
для перетворення необроблених зрізів облікових записів (і, за потреби, байтів інструкцій) у строго типізовані структури, одночасно забезпечуючи дотримання всіх обмежень.
Валідація облікових записів
Зазвичай ми обробляємо всі конкретні перевірки, які не потребують подвійного запозичення (запозичення як у валідації облікового запису, так і потенційно в процесі) у кожній реалізації TryFrom
. Це дозволяє функції process()
, де відбувається вся логіка інструкції, залишатися максимально чистою.
Ми починаємо з реалізації структури облікового запису, необхідної для інструкції, подібно до Anchor's Context
.
Примітка: На відміну від Anchor, у цій структурі облікового запису ми включаємо лише ті облікові записи, які хочемо використовувати в процесі, і позначаємо як _
решту облікових записів, які потрібні в інструкції, але не будуть використовуватися (як-от SystemProgram
).
Для чогось на кшталт Vault
, це виглядатиме так:
pub struct DepositAccounts<'a> {
pub owner: &'a AccountInfo,
pub vault: &'a AccountInfo,
}
Тепер, коли ми знаємо, які облікові записи хочемо використовувати в нашій інструкції, ми можемо використати трейт TryFrom
для десеріалізації та виконання всіх необхідних перевірок:
impl<'a> TryFrom<&'a [AccountInfo]> for DepositAccounts<'a> {
type Error = ProgramError;
fn try_from(accounts: &'a [AccountInfo]) -> Result<Self, Self::Error> {
// 1. Destructure the slice
let [owner, vault, _] = accounts else {
return Err(ProgramError::NotEnoughAccountKeys);
};
// 2. Custom checks
if !owner.is_signer() {
return Err(ProgramError::InvalidAccountOwner);
}
if !vault.is_owned_by(&pinocchio_system::ID) {
return Err(ProgramError::InvalidAccountOwner);
}
// 3. Return the validated struct
Ok(Self { owner, vault })
}
}
Як бачите, у цій інструкції ми збираємося використовувати CPI SystemProgram
для передачі лампортів від власника до сховища, але нам не потрібно використовувати SystemProgram у самій інструкції. Програма просто повинна бути включена в інструкцію, тому ми можемо передати її як _
.
Потім ми виконуємо власні перевірки облікових записів, подібні до перевірок Anchor's Signer
та SystemAccount
, і повертаємо валідовану структуру.
Валідація інструкцій
Валідація інструкцій слідує подібному шаблону до валідації облікових записів. Ми використовуємо трейт TryFrom
для валідації та десеріалізації даних інструкції в строго типізовані структури, зберігаючи бізнес-логіку в process()
чистою та сфокусованою.
Почнімо з визначення структури, яка представляє дані нашої інструкції:
pub struct DepositInstructionData {
pub amount: u64,
}
Потім ми реалізуємо TryFrom
для перевірки даних інструкції та перетворення їх у наш структурований тип. Це включає:
- Перевірку відповідності довжини даних очікуваному розміру
- Перетворення байтового зрізу в наш конкретний тип
- Виконання будь-яких необхідних перевірок валідації
Ось як виглядає реалізація:
impl<'a> TryFrom<&'a [u8]> for DepositInstructionData {
type Error = ProgramError;
fn try_from(data: &'a [u8]) -> Result<Self, Self::Error> {
// 1. Verify the data length matches a u64 (8 bytes)
if data.len() != core::mem::size_of::<u64>() {
return Err(ProgramError::InvalidInstructionData);
}
// 2. Convert the byte slice to a u64
let amount = u64::from_le_bytes(data.try_into().unwrap());
// 3. Validate the amount (e.g., ensure it's not zero)
if amount == 0 {
return Err(ProgramError::InvalidInstructionData);
}
Ok(Self { amount })
}
}
Цей шаблон дозволяє нам:
- Перевіряти дані інструкції перед тим, як вони потраплять до бізнес-логіки
- Тримати логіку валідації окремо від основної функціональності
- Надавати чіткі повідомлення про помилки, коли валідація не проходить
- Підтримувати типову безпеку в усій програмі