Сховище Secp256r1

Сховище є фундаментальним будівельним блоком у DeFi, який забезпечує безпечний спосіб зберігання активів для користувачів.
У цьому завданні ми створимо сховище, яке використовує підписи Secp256r1 для перевірки транзакцій. Це особливо цікаво, оскільки Secp256r1 — це та сама еліптична крива, яка використовується сучасними методами автентифікації, такими як ключі доступу, що дозволяють користувачам підписувати транзакції за допомогою біометричної автентифікації (наприклад, Face ID або Touch ID) замість традиційних підписів на основі гаманця.
Ключовою інновацією тут є відокремлення оплати комісій за транзакції від фактичної автентифікації користувача. Це означає, що хоча користувачі можуть автентифікувати транзакції за допомогою своїх підписів Secp256r1 (які можуть бути згенеровані через сучасні методи автентифікації), фактичні комісії за транзакції може сплачувати постачальник послуг. Це створює більш плавний досвід користувача, зберігаючи безпеку.
У цьому завданні ми оновимо просте сховище lamport, яке ми створили в Завданні Pinocchio Vault, щоб дозволити використовувати підписи Secp256r1 як метод перевірки для транзакцій.
Installation
Перш ніж почати, переконайтеся, що Rust і Pinocchio встановлені. Потім у терміналі виконайте:
# create workspace
cargo new blueshift_secp256r1_vault --lib --edition 2021
cd blueshift_secp256r1_vaultДодайте Pinocchio та сумісний з Pinocchio крейт Secp256r1
cargo add pinocchio pinocchio-system pinocchio-secp256r1-instructionОголосіть типи крейту в Cargo.toml, щоб згенерувати артефакти розгортання в target/deploy:
[lib]
crate-type = ["lib", "cdylib"]Template
Почнімо з базової структури програми. Ми реалізуємо все в lib.rs, оскільки це проста програма. Ось початковий шаблон з основними компонентами, які нам знадобляться:
#![no_std]
use pinocchio::{account_info::AccountInfo, entrypoint, nostd_panic_handler, program_error::ProgramError, pubkey::Pubkey, ProgramResult};
entrypoint!(process_instruction);
nostd_panic_handler!();
pub mod instructions;
pub use instructions::*;
// 22222222222222222222222222222222222222222222
pub const ID: Pubkey = [
0x0f, 0x1e, 0x6b, 0x14, 0x21, 0xc0, 0x4a, 0x07,
0x04, 0x31, 0x26, 0x5c, 0x19, 0xc5, 0xbb, 0xee,
0x19, 0x92, 0xba, 0xe8, 0xaf, 0xd1, 0xcd, 0x07,
0x8e, 0xf8, 0xaf, 0x70, 0x47, 0xdc, 0x11, 0xf7,
];
fn process_instruction(
_program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
match instruction_data.split_first() {
Some((Deposit::DISCRIMINATOR, data)) => Deposit::try_from((data, accounts))?.process(),
Some((Withdraw::DISCRIMINATOR, _)) => Withdraw::try_from(accounts)?.process(),
_ => Err(ProgramError::InvalidInstructionData),
}
}Deposit
Інструкція депозиту виконує такі кроки:
Перевіряє, що сховище порожнє (має нуль лампортів), щоб запобігти подвійним депозитам
Забезпечує, щоб сума депозиту перевищувала мінімум, звільнений від орендної плати для базового рахунку
Переказує лампорти від платника до сховища, використовуючи CPI до System Program
Основна відмінність між звичайним сховищем і сховищем Secp256r1 полягає в способі отримання PDA та в тому, хто вважається "власником".
Оскільки з підписами Secp256r1 власнику фактичного гаманця не потрібно платити за комісії транзакцій, ми змінюємо обліковий запис owner на більш загальну конвенцію іменування, як-от payer.
Отже, структура облікового запису для deposit виглядатиме так:
pub struct DepositAccounts<'a> {
pub payer: &'a AccountInfo,
pub vault: &'a AccountInfo,
}
impl<'a> TryFrom<&'a [AccountInfo]> for DepositAccounts<'a> {
type Error = ProgramError;
fn try_from(accounts: &'a [AccountInfo]) -> Result<Self, Self::Error> {
let [payer, vault, _] = accounts else {
return Err(ProgramError::NotEnoughAccountKeys);
};
// Accounts Checks
if !payer.is_signer() {
return Err(ProgramError::InvalidAccountOwner);
}
if !vault.is_owned_by(&pinocchio_system::ID) {
return Err(ProgramError::InvalidAccountOwner);
}
if vault.lamports().ne(&0) {
return Err(ProgramError::InvalidAccountData);
}
// Return the accounts
Ok(Self { payer, vault })
}
}Розглянемо кожну перевірку облікового запису:
payer: Має бути підписантом, оскільки йому потрібно авторизувати переказ лампортівvault:Має належати System Program
Повинен мати нуль лампортів (забезпечує "свіжий" депозит)
Для vault ми перевіримо, чи:
Він отриманий з правильних сідів
Відповідає очікуваній адресі PDA Оскільки частина сіду знаходиться в
instruction_data, до якого у нас немає доступу на цей момент.
Тепер реалізуємо структуру даних інструкції:
#[repr(C, packed)]
pub struct DepositInstructionData {
pub pubkey: Secp256r1Pubkey,
pub amount: u64,
}
impl<'a> TryFrom<&'a [u8]> for DepositInstructionData {
type Error = ProgramError;
fn try_from(data: &'a [u8]) -> Result<Self, Self::Error> {
if data.len() != size_of::<Self>() {
return Err(ProgramError::InvalidInstructionData);
}
let (pubkey_bytes, amount_bytes) = data.split_at(size_of::<Secp256r1Pubkey>());
Ok(Self {
pubkey: pubkey_bytes.try_into().unwrap(),
amount: u64::from_le_bytes(amount_bytes.try_into().unwrap()),
})
}
}Ми десеріалізуємо дані інструкції в структуру DepositInstructionData, яка містить:
pubkey: Відкритий ключ Secp256r1 користувача, який здійснює депозитamount: Кількість лампортів для депозиту
Хоча використання unwrap зазвичай не рекомендується в продакшн-коді, у цьому випадку воно використовується безпечно, оскільки ми вже перевірили довжину даних у методі try_from. Якщо довжина даних не відповідає, буде повернуто помилку до того, як ми дійдемо до цього місця.
Нарешті, реалізуємо інструкцію депозиту:
pub struct Deposit<'a> {
pub accounts: DepositAccounts<'a>,
pub instruction_data: DepositInstructionData,
}
impl<'a> TryFrom<(&'a [u8], &'a [AccountInfo])> for Deposit<'a> {
type Error = ProgramError;
fn try_from((data, accounts): (&'a [u8], &'a [AccountInfo])) -> Result<Self, Self::Error> {
let accounts = DepositAccounts::try_from(accounts)?;
let instruction_data = DepositInstructionData::try_from(data)?;
Ok(Self {
accounts,
instruction_data,
})
}
}
impl<'a> Deposit<'a> {
pub const DISCRIMINATOR: &'a u8 = &0;
pub fn process(&mut self) -> ProgramResult {
// Check vault address
let (vault_key, _) = find_program_address(
&[
b"vault",
&self.instruction_data.pubkey[..1],
&self.instruction_data.pubkey[1..33]
],
&crate::ID
);
if vault_key.ne(self.accounts.vault.key()) {
return Err(ProgramError::InvalidAccountOwner);
}
Transfer {
from: self.accounts.payer,
to: self.accounts.vault,
lamports: self.instruction_data.amount,
}
.invoke()
}
}Як було зазначено раніше, нам потрібно перевірити, що PDA сховища отримано з правильних сідів. У цьому сховищі на основі Secp256r1 ми використовуємо Secp256r1Pubkey як частину сідів замість традиційного відкритого ключа власника. Це важливий захід безпеки, який гарантує, що лише власник відповідного ключа Secp256r1 може отримати доступ до сховища.
Secp256r1Pubkey має довжину 33 байти, оскільки використовує стиснуте представлення точки для відкритих ключів еліптичної кривої. Цей формат складається з:
1 байт для парності точки (вказує, чи є y-координата парною або непарною)
32 байти для x-координати
Оскільки функція find_program_address у Solana має обмеження в 32 байти для кожного сіда, нам потрібно розділити Secp256r1Pubkey на дві частини:
Байт парності (
pubkey[..1])Байти x-координати (
pubkey[1..33])
Withdraw
Інструкція виведення виконує такі кроки:
Перевіряє, що сховище містить лампорти (не порожнє)
Використовує PDA сховища для підписання переказу від свого імені
Переказує всі лампорти зі сховища назад власнику
Спочатку визначимо структуру акаунта для виведення:
pub struct WithdrawAccounts<'a> {
pub payer: &'a AccountInfo,
pub vault: &'a AccountInfo,
pub instructions: &'a AccountInfo,
}
impl<'a> TryFrom<&'a [AccountInfo]> for WithdrawAccounts<'a> {
type Error = ProgramError;
fn try_from(accounts: &'a [AccountInfo]) -> Result<Self, Self::Error> {
let [payer, vault, instructions, _system_program] = accounts else {
return Err(ProgramError::NotEnoughAccountKeys);
};
if !payer.is_signer() {
return Err(ProgramError::InvalidAccountOwner);
}
if !vault.is_owned_by(&pinocchio_system::ID) {
return Err(ProgramError::InvalidAccountOwner);
}
if vault.lamports().eq(&0) {
return Err(ProgramError::InvalidAccountData);
}
Ok(Self { payer, vault, instructions })
}
}Тепер реалізуємо структуру даних інструкції:
pub struct WithdrawInstructionData {
pub bump: [u8;1]
}
impl<'a> TryFrom<&'a [u8]> for WithdrawInstructionData {
type Error = ProgramError;
fn try_from(data: &'a [u8]) -> Result<Self, Self::Error> {
Ok(Self {
bump: [*data.first().ok_or(ProgramError::InvalidInstructionData)?],
})
}
}Як бачите, з метою оптимізації ми передали bump як дані інструкції, щоб не виводити його в process(), який вже є "важким" через всі інші необхідні перевірки.
Нарешті, реалізуємо інструкцію withdraw:
pub struct Withdraw<'a> {
pub accounts: WithdrawAccounts<'a>,
pub instruction_data: WithdrawInstructionData,
}
impl<'a> TryFrom<(&'a [u8], &'a [AccountInfo])> for Withdraw<'a> {
type Error = ProgramError;
fn try_from((data, accounts): (&'a [u8], &'a [AccountInfo])) -> Result<Self, Self::Error> {
let accounts = WithdrawAccounts::try_from(accounts)?;
let instruction_data = WithdrawInstructionData::try_from(data)?;
Ok(Self {
accounts,
instruction_data,
})
}
}
impl<'a> Withdraw<'a> {
pub const DISCRIMINATOR: &'a u8 = &1;
pub fn process(&mut self) -> ProgramResult {
// Deserialize our instructions
let instructions: Instructions<Ref<[u8]>> = Instructions::try_from(self.accounts.instructions)?;
// Get instruction directly after this one
let ix: IntrospectedInstruction = instructions.get_instruction_relative(1)?;
// Get Secp256r1 instruction
let secp256r1_ix = Secp256r1Instruction::try_from(&ix)?;
// Enforce that we only have one signature
if secp256r1_ix.num_signatures() != 1 {
return Err(ProgramError::InvalidInstructionData);
}
// Enforce that the signer of the first signature is our PDA owner
let signer: Secp256r1Pubkey = *secp256r1_ix.get_signer(0)?;
// Check that our fee payer is the correct
let (payer, expiry) = secp256r1_ix
.get_message_data(0)?
.split_at_checked(32)
.ok_or(ProgramError::InvalidInstructionData)?;
if self.accounts.payer.key().ne(payer) {
return Err(ProgramError::InvalidAccountOwner);
}
// Get current timestamp
let now = Clock::get()?.unix_timestamp;
// Get signature expiry timestamp
let expiry = i64::from_le_bytes(
expiry
.try_into()
.map_err(|_| ProgramError::InvalidInstructionData)?
);
if now > expiry {
return Err(ProgramError::InvalidInstructionData);
}
// Create signer seeds for our CPI
let seeds = [
Seed::from(b"vault"),
Seed::from(signer[..1].as_ref()),
Seed::from(signer[1..].as_ref()),
Seed::from(&self.instruction_data.bump),
];
let signers = [Signer::from(&seeds)];
Transfer {
from: self.accounts.vault,
to: self.accounts.payer,
lamports: self.accounts.vault.lamports(),
}
.invoke_signed(&signers)
}
}Процес виведення включає кілька перевірок безпеки, щоб гарантувати легітимність транзакції. Розглянемо, як ми перевіряємо підпис Secp256r1 і захищаємося від потенційних атак:
Інтроспекція інструкцій
Ми використовуємо змінну інструкції для перевірки наступної інструкції в транзакції
Це дозволяє нам перевірити підпис Secp256r1, який підтверджує володіння ключем підпису
Перевірка підпису для Secp256r1 завжди відбувається в окремій інструкції
Перевірка підпису
let secp256r1_ix = Secp256r1Instruction::try_from(&ix)?;
if secp256r1_ix.num_signatures() != 1 {
return Err(ProgramError::InvalidInstructionData);
}
let signer: Secp256r1Pubkey = *secp256r1_ix.get_signer(0)?;Ми перевіряємо, що є рівно один підпис
Ми витягуємо відкритий ключ підписувача, який повинен збігатися з тим, що використовувався для створення PDA сховища для цілей перевірки
Валідація повідомлення
let (payer, expiry) = secp256r1_ix.get_message_data(0)?.split_at_checked(32)?;
if self.accounts.payer.key().ne(payer) {
return Err(ProgramError::InvalidAccountOwner);
}Підписане повідомлення містить дві критично важливі частини інформації:
Адреса призначеного отримувача (32 байти)
Часова мітка, коли підпис закінчується (8 байтів)
Це запобігає MEV-атакам, коли хтось міг би перехопити і повторно використати дійсний підпис, передавши інший
payerі претендувати на суму, що знаходиться в сховищі
Перевірка терміну дії
let now = Clock::get()?.unix_timestamp;
let expiry = i64::from_le_bytes(expiry.try_into()?);
if now > expiry {
return Err(ProgramError::InvalidInstructionData);
}Ми перевіряємо, що термін дії підпису не закінчився
Це додає часовий рівень безпеки для запобігання повторному використанню підпису
Час закінчення терміну дії слід розглядати як "рефракторний період"; нове сховище не може бути створене до закінчення терміну дії, інакше воно може бути повторно використане без відома справжнього власника.
Підписання PDA
let seeds = [
Seed::from(b"vault"),
Seed::from(signer[..1].as_ref()),
Seed::from(signer[1..].as_ref()),
Seed::from(&self.instruction_data.bump),
];Нарешті, ми використовуємо перевірений відкритий ключ для створення насіння PDA
Це гарантує, що тільки законний власник ключа Secp256r1 може підписати транзакцію виведення
Conclusion
Тепер ви можете перевірити свою програму за допомогою наших модульних тестів і отримати свої NFT!
Почніть з побудови вашої програми, використовуючи наступну команду в терміналі:
cargo build-sbfЦе згенерувало файл .so безпосередньо у вашій папці target/deploy.
Тепер натисніть на кнопку take challenge і перетягніть туди файл!