Rust
Pinocchio Secp256r1 Vault

Pinocchio Secp256r1 Vault

30 Graduates

Pinocchio Secp256r1 Vault

Сховище Secp256r1

Завдання Pinocchio Secp256r1 Vault

Сховище є фундаментальним будівельним блоком у 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:

toml
[lib]
crate-type = ["lib", "cdylib"]

Template

Почнімо з базової структури програми. Ми реалізуємо все в lib.rs, оскільки це проста програма. Ось початковий шаблон з основними компонентами, які нам знадобляться:

rust
#![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

Інструкція депозиту виконує такі кроки:

  1. Перевіряє, що сховище порожнє (має нуль лампортів), щоб запобігти подвійним депозитам

  2. Забезпечує, щоб сума депозиту перевищувала мінімум, звільнений від орендної плати для базового рахунку

  3. Переказує лампорти від платника до сховища, використовуючи CPI до System Program

Основна відмінність між звичайним сховищем і сховищем Secp256r1 полягає в способі отримання PDA та в тому, хто вважається "власником".

Оскільки з підписами Secp256r1 власнику фактичного гаманця не потрібно платити за комісії транзакцій, ми змінюємо обліковий запис owner на більш загальну конвенцію іменування, як-от payer.

Отже, структура облікового запису для deposit виглядатиме так:

rust
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 })
    }
}

Розглянемо кожну перевірку облікового запису:

  1. payer: Має бути підписантом, оскільки йому потрібно авторизувати переказ лампортів

  2. vault:

    • Має належати System Program

    • Повинен мати нуль лампортів (забезпечує "свіжий" депозит)

Для vault ми перевіримо, чи:

  • Він отриманий з правильних сідів

  • Відповідає очікуваній адресі PDA Оскільки частина сіду знаходиться в instruction_data, до якого у нас немає доступу на цей момент.

Тепер реалізуємо структуру даних інструкції:

rust
#[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. Якщо довжина даних не відповідає, буде повернуто помилку до того, як ми дійдемо до цього місця.

Нарешті, реалізуємо інструкцію депозиту:

rust
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 на дві частини:

  1. Байт парності (pubkey[..1])

  2. Байти x-координати (pubkey[1..33])

Withdraw

Інструкція виведення виконує такі кроки:

  1. Перевіряє, що сховище містить лампорти (не порожнє)

  2. Використовує PDA сховища для підписання переказу від свого імені

  3. Переказує всі лампорти зі сховища назад власнику

Спочатку визначимо структуру акаунта для виведення:

rust
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 })
    }
}

Тепер реалізуємо структуру даних інструкції:

rust
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:

rust
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 і захищаємося від потенційних атак:

  1. Інтроспекція інструкцій

  • Ми використовуємо змінну інструкції для перевірки наступної інструкції в транзакції

  • Це дозволяє нам перевірити підпис Secp256r1, який підтверджує володіння ключем підпису

  • Перевірка підпису для Secp256r1 завжди відбувається в окремій інструкції

  1. Перевірка підпису

rust
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 сховища для цілей перевірки

  1. Валідація повідомлення

rust
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 і претендувати на суму, що знаходиться в сховищі

  1. Перевірка терміну дії

rust
let now = Clock::get()?.unix_timestamp;
let expiry = i64::from_le_bytes(expiry.try_into()?);
if now > expiry {
    return Err(ProgramError::InvalidInstructionData);
}
  • Ми перевіряємо, що термін дії підпису не закінчився

  • Це додає часовий рівень безпеки для запобігання повторному використанню підпису

  • Час закінчення терміну дії слід розглядати як "рефракторний період"; нове сховище не може бути створене до закінчення терміну дії, інакше воно може бути повторно використане без відома справжнього власника.

  1. Підписання PDA

rust
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 і перетягніть туди файл!

Готові прийняти завдання?
Blueshift © 2025Commit: e573eab