Vault Secp256r1

Vault adalah blok bangunan fundamental dalam DeFi yang menyediakan cara aman bagi pengguna untuk menyimpan aset mereka.
Dalam tantangan ini, kita akan membangun vault yang menggunakan tanda tangan Secp256r1 untuk verifikasi transaksi. Ini sangat menarik karena Secp256r1 adalah kurva eliptik yang sama yang digunakan oleh metode autentikasi modern seperti passkey, yang memungkinkan pengguna menandatangani transaksi menggunakan autentikasi biometrik (seperti Face ID atau Touch ID) alih-alih tanda tangan berbasis dompet tradisional.
Inovasi utama di sini adalah kita memisahkan pembayaran biaya transaksi dari autentikasi pengguna yang sebenarnya. Ini berarti bahwa sementara pengguna dapat mengautentikasi transaksi menggunakan tanda tangan Secp256r1 mereka (yang dapat dihasilkan melalui metode autentikasi modern), biaya transaksi sebenarnya dapat dibayar oleh penyedia layanan. Ini menciptakan pengalaman pengguna yang lebih mulus sambil tetap menjaga keamanan.
Dalam tantangan ini, kita akan memperbarui vault lamport sederhana yang kita bangun di Tantangan Vault Pinocchio untuk memungkinkan tanda tangan Secp256r1 sebagai metode verifikasi untuk transaksi.
Instalasi
Sebelum memulai, pastikan Rust dan Pinocchio sudah terpasang. Kemudian di terminal Anda jalankan:
# create workspace
cargo new blueshift_secp256r1_vault --lib --edition 2021
cd blueshift_secp256r1_vaultTambahkan Pinocchio dan crate yang kompatibel dengan Pinocchio Secp256r1
cargo add pinocchio pinocchio-system pinocchio-secp256r1-instructionDeklarasikan tipe crate di Cargo.toml untuk menghasilkan artefak deployment di target/deploy:
[lib]
crate-type = ["lib", "cdylib"]Template
Mari mulai dengan struktur program dasar. Kita akan mengimplementasikan semuanya di lib.rs karena ini adalah program yang sederhana. Berikut adalah template awal dengan komponen inti yang kita butuhkan:
#![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
Instruksi deposit melakukan langkah-langkah berikut:
Memverifikasi bahwa vault kosong (memiliki nol lamport) untuk mencegah deposit ganda
Memastikan jumlah deposit melebihi minimum bebas sewa untuk akun dasar
Mentransfer lamport dari pembayar ke vault menggunakan CPI ke Program Sistem
Perbedaan utama antara vault normal dan vault Secp256r1 adalah cara kita menurunkan PDA dan siapa yang dianggap sebagai "pemilik".
Karena dengan tanda tangan Secp256r1, pemilik dompet sebenarnya tidak perlu membayar biaya transaksi, kita mengubah akun owner menjadi konvensi penamaan yang lebih umum seperti payer.
Jadi struktur akun untuk deposit akan terlihat seperti ini:
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 })
}
}Mari kita uraikan setiap pemeriksaan akun:
payer: Harus menjadi penandatangan karena mereka perlu mengotorisasi transfer lamportvault:Harus dimiliki oleh Program Sistem
Harus memiliki nol lamport (memastikan deposit "baru")
Untuk vault kita akan memeriksa apakah:
Diturunkan dari seed yang benar
Cocok dengan alamat PDA yang diharapkan Karena sebagian dari seed ada di
instruction_datayang tidak kita miliki aksesnya saat ini.
Sekarang mari kita implementasikan struktur data instruksi:
#[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()),
})
}
}Kita mendeserialkan data instruksi menjadi struktur DepositInstructionData yang berisi:
pubkey: Kunci publik Secp256r1 dari pengguna yang melakukan depositamount: Jumlah lamport yang akan didepositkan
Meskipun unwrap umumnya tidak dianjurkan dalam kode produksi, dalam kasus ini, digunakan dengan aman karena kita sudah memvalidasi panjang data dalam metode try_from. Jika panjang data tidak cocok, akan mengembalikan error sebelum mencapai titik ini.
Akhirnya, mari kita implementasikan instruksi deposit:
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()
}
}Seperti yang disebutkan sebelumnya, kita perlu memverifikasi bahwa PDA vault berasal dari seeds yang benar. Dalam vault berbasis Secp256r1 ini, kita menggunakan Secp256r1Pubkey sebagai bagian dari seeds alih-alih public key pemilik yang tradisional. Ini adalah langkah keamanan penting yang memastikan hanya pemegang kunci Secp256r1 yang sesuai yang dapat mengakses vault.
Secp256r1Pubkey memiliki panjang 33 byte karena menggunakan representasi titik terkompresi untuk kunci publik kurva eliptik. Format ini terdiri dari:
1 byte untuk paritas titik (menunjukkan apakah koordinat y genap atau ganjil)
32 byte untuk koordinat x
Karena fungsi find_program_address Solana memiliki batas 32 byte untuk setiap seed, kita perlu membagi Secp256r1Pubkey menjadi dua bagian:
Byte paritas (
pubkey[..1])Byte koordinat x (
pubkey[1..33])
Withdraw
Instruksi withdraw melakukan langkah-langkah berikut:
Memverifikasi vault berisi lamports (tidak kosong)
Menggunakan PDA vault untuk menandatangani transfer atas namanya sendiri
Mentransfer semua lamports dari vault kembali ke pemilik
Pertama, mari kita definisikan struct akun untuk withdraw:
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 })
}
}Sekarang mari kita implementasikan struct data instruksi:
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)?],
})
}
}Seperti yang Anda lihat, untuk tujuan optimasi kita meneruskan bump sebagai data instruksi agar tidak perlu menurunkannya di process() yang sudah "berat" karena semua pemeriksaan lain yang diperlukan.
Akhirnya, mari kita implementasikan instruksi 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)
}
}Proses penarikan melibatkan beberapa pemeriksaan keamanan untuk memastikan transaksi sah. Mari kita uraikan bagaimana kita memverifikasi tanda tangan Secp256r1 dan melindungi dari potensi serangan:
Introspeksi Instruksi
Kami menggunakan sysvar instruksi untuk memeriksa instruksi berikutnya dalam transaksi
Ini memungkinkan kami memverifikasi tanda tangan Secp256r1 yang membuktikan kepemilikan kunci penandatanganan
Verifikasi tanda tangan untuk Secp256r1 selalu terjadi dalam instruksi terpisah
Verifikasi Tanda Tangan
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)?;Kami memverifikasi bahwa hanya ada satu tanda tangan
Kami mengekstrak kunci publik penandatangan, yang harus cocok dengan yang digunakan untuk membuat PDA vault untuk tujuan verifikasi
Validasi Pesan
let (payer, expiry) = secp256r1_ix.get_message_data(0)?.split_at_checked(32)?;
if self.accounts.payer.key().ne(payer) {
return Err(ProgramError::InvalidAccountOwner);
}Pesan yang ditandatangani berisi dua informasi penting:
Alamat penerima yang dituju (32 byte)
Stempel waktu untuk kapan tanda tangan kedaluwarsa (8 byte)
Ini mencegah serangan MEV di mana seseorang dapat menangkap dan menggunakan kembali tanda tangan yang valid dengan meneruskan
payerlain dan mengklaim jumlah yang ada di vault
Pemeriksaan Kedaluwarsa
let now = Clock::get()?.unix_timestamp;
let expiry = i64::from_le_bytes(expiry.try_into()?);
if now > expiry {
return Err(ProgramError::InvalidInstructionData);
}Kami memverifikasi bahwa tanda tangan belum kedaluwarsa
Ini menambahkan lapisan keamanan berbasis waktu untuk mencegah penggunaan kembali tanda tangan
Waktu kedaluwarsa harus dianggap sebagai "periode refrakter"; tidak ada vault baru yang dapat dibuat sampai kedaluwarsa atau bisa digunakan kembali tanpa sepengetahuan pemilik sebenarnya.
Penandatanganan 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),
];Akhirnya, kami menggunakan kunci publik yang telah diverifikasi untuk membuat seed PDA
Ini memastikan hanya pemegang kunci Secp256r1 yang sah yang dapat menandatangani transaksi penarikan
Conclusion
Sekarang Anda dapat menguji program Anda dengan unit test kami dan mengklaim NFT Anda!
Mulailah dengan membangun program Anda menggunakan perintah berikut di terminal Anda:
cargo build-sbfIni menghasilkan file .so langsung di folder target/deploy Anda.
Sekarang klik tombol take challenge dan letakkan file tersebut di sana!