The Secp256r1 Vault
A vault is a fundamental building block in DeFi that provides a secure way for users to store their assets.
In this challenge, we'll build a vault that uses Secp256r1 signatures for transaction verification. This is particularly interesting because Secp256r1 is the same elliptic curve used by modern authentication methods like passkeys, which allow users to sign transactions using biometric authentication (like Face ID or Touch ID) instead of traditional wallet-based signatures.
The key innovation here is that we're decoupling the payment of transaction fees from the actual user authentication. This means that while users can authenticate transactions using their Secp256r1 signatures (which can be generated through modern authentication methods), the actual transaction fees can be paid by a service provider. This creates a more seamless user experience while maintaining security.
In this challenge, we'll update the simple lamport vault that we built in the Pinocchio Vault Challenge to allow Secp256r1 signatures as a verification method for transactions.
Installation
Before you begin, be sure Rust and Pinocchio are installed. Then in your terminal run:
# create workspace
cargo new blueshift_secp256r1_vault --lib --edition 2021
cd blueshift_secp256r1_vault
Add Pinocchio and the Pinocchio-compatible Secp256r1
crate
cargo add pinocchio pinocchio-system pinocchio-secp256r1-instruction
Declare the crate types in Cargo.toml
to generate deployment artifacts in target/deploy
:
[lib]
crate-type = ["lib", "cdylib"]
Template
Let's start with the basic program structure. We'll implement everything in lib.rs
since this is a straightforward program. Here's the initial template with the core components we'll need:
#![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
The deposit instruction performs the following steps:
- Verifies the vault is empty (has zero lamports) to prevent double deposits
- Ensures the deposit amount exceeds the rent-exempt minimum for a basic account
- Transfers lamports from the payer to the vault using a CPI to the System Program
The main difference between a normal vault and the Secp256r1 vault is the way that we derive the PDA and who is considered an "owner".
Since with Secp256r1 signatures the owner of the actual wallet doesn't need to pay for the transaction fees, we change the owner
account to a more general naming convention like payer
.
So the account struct for deposit
will look like this:
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 })
}
}
Let's break down each account check:
payer
: Must be a signer since they need to authorize the transfer of lamportsvault
:- Must be owned by the System Program
- Must have zero lamports (ensures "fresh" deposit)
For the vault
we're going to then check if:
- It's derived from the correct seeds
- Match the expected PDA address
Since part of the seed is in the
instruction_data
that we don't have access at this time.
Now let's implement the instruction data struct:
#[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()),
})
}
}
We deserialize the instruction data into a DepositInstructionData
struct that contains:
pubkey
: The Secp256r1 public key of the user making the depositamount
: The amount of lamports to deposit
While unwrap is generally discouraged in production code, in this case, it is used safely because we already validated the length of the data in the try_from
method. If the data length doesn't match, it will return an error before reaching this point.
Finally, let's implement the deposit instruction:
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()
}
}
As mentioned earlier, we need to verify that the vault PDA is derived from the correct seeds. In this Secp256r1-based vault, we use the Secp256r1Pubkey
as part of the seeds instead of the traditional owner's public key. This is a crucial security measure that ensures only the holder of the corresponding Secp256r1 key can access the vault.
The Secp256r1Pubkey
is 33 bytes long because it uses compressed point representation for elliptic curve public keys. This format consists of:
- 1 byte for the point's parity (indicating whether the y-coordinate is even or odd)
- 32 bytes for the x-coordinate
Since Solana's find_program_address
function has a 32-byte limit for each seed, we need to split the Secp256r1Pubkey
into two parts:
- The parity byte (
pubkey[..1]
) - The x-coordinate bytes (
pubkey[1..33]
)
Withdraw
The withdraw instruction performs the following steps:
- Verifies the vault contains lamports (is not empty)
- Uses the vault's PDA to sign the transfer on its own behalf
- Transfers all lamports from the vault back to the owner
First, let's define the account struct for 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 })
}
}
Now let's implement the instruction data struct:
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)?],
})
}
}
As you can see, for optimization purposese we passed the bump as instruction data to not have to derive it in the process()
that is already "heavy" because of all the other checks needed.
Finally, let's implement the withdraw
instruction:
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)
}
}
The withdrawal process involves several security checks to ensure the transaction is legitimate. Let's break down how we verify the Secp256r1 signature and protect against potential attacks:
- Instruction Introspection
- We use the instruction sysvar to inspect the next instruction in the transaction
- This allows us to verify the Secp256r1 signature that proves ownership of the signing key
- The signature verification for Secp256r1 always happens in a separate instruction
- Signature Verification
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)?;
- We verify that there's exactly one signature
- We extract the signer's public key, which must match the one used to create the vault PDA for verification purposes
- Message Validation
let (payer, expiry) = secp256r1_ix.get_message_data(0)?.split_at_checked(32)?;
if self.accounts.payer.key().ne(payer) {
return Err(ProgramError::InvalidAccountOwner);
}
- The signed message contains two critical pieces of information:
- The intended recipient's address (32 bytes)
- A timestamp for when the signature expires (8 bytes)
- This prevents MEV attacks where someone could intercept and reuse a valid signature by passing another
payer
and claiming the amount that sits in the vault
- Expiry Check
let now = Clock::get()?.unix_timestamp;
let expiry = i64::from_le_bytes(expiry.try_into()?);
if now > expiry {
return Err(ProgramError::InvalidInstructionData);
}
- We verify the signature hasn't expired
- This adds a time-based security layer to prevent signature reuse
- The expiry time should be considered a "refractory period"; no new vault can be created until it expires or it could be reused without the knowledge of the actual owner.
- PDA Signing
let seeds = [
Seed::from(b"vault"),
Seed::from(signer[..1].as_ref()),
Seed::from(signer[1..].as_ref()),
Seed::from(&self.instruction_data.bump),
];
- Finally, we use the verified public key to create the PDA seeds
- This ensures only the legitimate Secp256r1 key holder can sign the withdrawal transaction
Conclusion
You can now test your program against our unit tests and claim your NFTs!
Start by building your program using the following command in your terminal:
cargo build-sbf
This generated a .so
file directly in your target/deploy
folder.
Now click on the take challenge
button and drop the file there!